+10
-2
.formatter.exs
+10
-2
.formatter.exs
···
1
1
# Used by "mix format"
2
2
[
3
-
inputs: ["{mix,.formatter,.credo}.exs", "{config,lib,test}/**/*.{ex,exs}"],
4
-
import_deps: [:typedstruct]
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"),
8
+
import_deps: [:typedstruct, :peri, :plug],
9
+
# excludes: ["lib/atproto/**/*.ex"],
10
+
export: [
11
+
locals_without_parens: [deflexicon: 1]
12
+
]
5
13
]
+10
-16
.gitignore
+10
-16
.gitignore
···
1
-
# The directory Mix will write compiled artifacts to.
2
1
/_build/
3
-
4
-
# If you run "mix test --cover", coverage assets end up here.
5
2
/cover/
6
-
7
-
# The directory Mix downloads your dependencies sources to.
8
3
/deps/
9
-
10
-
# Where third-party dependencies like ExDoc output generated docs.
11
4
/doc/
12
-
13
-
# If the VM crashes, it generates a dump, let's ignore it too.
14
5
erl_crash.dump
15
-
16
-
# Also ignore archive artifacts (built via "mix archive.build").
17
6
*.ez
18
-
19
-
# Ignore package tarball (built via "mix hex.build").
20
7
atex-*.tar
21
-
22
-
# Temporary files, for example, from tests.
23
8
/tmp/
9
+
/priv/dets/
24
10
25
11
.envrc
26
-
.direnv
12
+
.direnv
13
+
.vscode/
14
+
.elixir_ls
15
+
lexicons
16
+
secrets
17
+
.DS_Store
18
+
CLAUDE.md
19
+
tmp
20
+
temp
.vscode/settings.json
.vscode/settings.json
This is a binary file and will not be displayed.
+40
AGENTS.md
+40
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
+
- When writing lists in documentation, use `-` as the list character.
25
+
- **Error Handling**: Return `{:ok, result}` or `{:error, reason}` tuples; use
26
+
pattern matching in case statements
27
+
- **Pattern Matching**: Prefer pattern matching over conditionals; use guards
28
+
when appropriate
29
+
- **Macros**: Use `deflexicon` macro for lexicon definitions; use `defschema`
30
+
(from Peri) for validation schemas
31
+
- **Tests**: Async by default (`use ExUnit.Case, async: true`), use doctests
32
+
where applicable
33
+
- **Dependencies**: Core deps include Peri (validation), Req (HTTP), JOSE
34
+
(JWT/OAuth), TypedStruct (structs)
35
+
36
+
## Important Notes
37
+
38
+
- **DO NOT modify** `lib/atproto/**/` - autogenerated from official AT Protocol
39
+
lexicons
40
+
- **Update CHANGELOG.md** when adding features, changes, or fixes
+112
-2
CHANGELOG.md
+112
-2
CHANGELOG.md
···
6
6
and this project adheres to
7
7
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
8
8
9
+
<!-- ## [Unreleased] -->
10
+
11
+
## [0.7.0] - 2026-01-07
12
+
13
+
### Breaking Changes
14
+
15
+
- `Atex.OAuth.Plug` now raises `Atex.OAuth.Error` exceptions instead of handling
16
+
error situations internally. Applications should implement `Plug.ErrorHandler`
17
+
to catch and gracefully handle them.
18
+
- `Atex.OAuth.Plug` now saves only the user's DID in the session instead of the
19
+
entire OAuth session object. Applications must use `Atex.OAuth.SessionStore`
20
+
to manage OAuth sessions.
21
+
- `Atex.XRPC.OAuthClient` has been overhauled to use `Atex.OAuth.SessionStore`
22
+
for retrieving and managing OAuth sessions, making it easier to use with not
23
+
needing to manually keep a Plug session in sync.
24
+
25
+
### Added
26
+
27
+
- `Atex.OAuth.SessionStore` behaviour and `Atex.OAuth.Session` struct for
28
+
managing OAuth sessions with pluggable storage backends.
29
+
- `Atex.OAuth.SessionStore.ETS` - in-memory session store implementation.
30
+
- `Atex.OAuth.SessionStore.DETS` - persistent disk-based session store
31
+
implementation.
32
+
- `Atex.OAuth.Plug` now requires a `:callback` option that is a MFA tuple
33
+
(Module, Function, Args), denoting a callback function to be invoked by after
34
+
a successful OAuth login. See [the OAuth example](./examples/oauth.ex) for a
35
+
simple usage of this.
36
+
- `Atex.OAuth.Permission` module for creating
37
+
[AT Protocol permission](https://atproto.com/specs/permission) strings for
38
+
OAuth.
39
+
- `Atex.OAuth.Error` exception module for OAuth flow errors. Contains both a
40
+
human-readable `message` string and a machine-readable `reason` atom for error
41
+
handling.
42
+
- `Atex.OAuth.Cache` module provides TTL caching for OAuth authorization server
43
+
metadata with a 1-hour default TTL to reduce load on third-party PDSs.
44
+
- `Atex.OAuth.get_authorization_server/2` and
45
+
`Atex.OAuth.get_authorization_server_metadata/2` now support an optional
46
+
`fresh` parameter to bypass the cache when needed.
47
+
48
+
### Changed
49
+
50
+
- `mix atex.lexicons` now adds `@moduledoc false` to generated modules to stop
51
+
them from automatically cluttering documentation.
52
+
- `Atex.IdentityResolver.Cache.ETS` now uses ConCache instead of ETS directly,
53
+
with a 1-hour TTL for cached identity information.
54
+
55
+
## [0.6.0] - 2025-11-25
56
+
57
+
### Breaking Changes
58
+
59
+
- `deflexicon` now converts all def names to be in snake_case instead of the
60
+
casing as written the lexicon.
61
+
62
+
### Added
63
+
64
+
- `deflexicon` now emits structs for records, objects, queries, and procedures.
65
+
- `Atex.XRPC.get/3` and `Atex.XRPC.post/3` now support having a lexicon struct
66
+
as the second argument instead of the method's name, making it easier to have
67
+
properly checked XRPC calls.
68
+
- Add pre-transpiled modules for the core `com.atproto` lexicons.
69
+
70
+
## [0.5.0] - 2025-10-11
71
+
72
+
### Breaking Changes
73
+
74
+
- Remove `Atex.HTTP` and associated modules as the abstraction caused a bit too
75
+
much complexities for how early atex is. It may come back in the future as
76
+
something more fleshed out once we're more stable.
77
+
- Rename `Atex.XRPC.Client` to `Atex.XRPC.LoginClient`
78
+
79
+
### Added
80
+
81
+
- `Atex.OAuth` module with utilites for handling some OAuth functionality.
82
+
- `Atex.OAuth.Plug` module (if Plug is loaded) which provides a basic but
83
+
complete OAuth flow, including storing the tokens in `Plug.Session`.
84
+
- `Atex.XRPC.Client` behaviour for implementing custom client variants.
85
+
- `Atex.XRPC` now supports using different client implementations.
86
+
- `Atex.XRPC.OAuthClient` to make XRPC calls on the behalf of a user who has
87
+
authenticated with ATProto OAuth.
88
+
89
+
## [0.4.0] - 2025-08-27
90
+
91
+
### Added
92
+
93
+
- `Atex.Lexicon` module that provides the `deflexicon` macro, taking in a JSON
94
+
Lexicon definition and converts it into a series of schemas for each
95
+
definition within it.
96
+
- `mix atex.lexicons` for converting lexicon JSON files into modules using
97
+
`deflexicon` easily.
98
+
99
+
## [0.3.0] - 2025-06-29
100
+
101
+
### Changed
102
+
103
+
- `Atex.XRPC.Adapter` renamed to `Atex.HTTP.Adapter`.
104
+
105
+
### Added
106
+
107
+
- `Atex.HTTP` module that delegates to the currently configured adapter.
108
+
- `Atex.HTTP.Response` struct to be returned by `Atex.HTTP.Adapter`.
109
+
- `Atex.IdentityResolver` module for resolving and validating an identity,
110
+
either by DID or a handle.
111
+
- Also has a pluggable cache (with a default ETS implementation) for keeping
112
+
some data locally.
113
+
9
114
## [0.2.0] - 2025-06-09
10
115
11
-
## Added
116
+
### Added
12
117
13
118
- `Atex.TID` module for manipulating ATProto TIDs.
14
119
- `Atex.Base32Sortable` module for encoding/decoding numbers as
···
19
124
20
125
Initial release.
21
126
22
-
[unreleased]: https://github.com/cometsh/atex/compare/v0.2.0...HEAD
127
+
[unreleased]: https://github.com/cometsh/atex/compare/v0.7.0...HEAD
128
+
[0.7.0]: https://github.com/cometsh/atex/releases/tag/v0.7.0
129
+
[0.6.0]: https://github.com/cometsh/atex/releases/tag/v0.6.0
130
+
[0.5.0]: https://github.com/cometsh/atex/releases/tag/v0.5.0
131
+
[0.4.0]: https://github.com/cometsh/atex/releases/tag/v0.4.0
132
+
[0.3.0]: https://github.com/cometsh/atex/releases/tag/v0.3.0
23
133
[0.2.0]: https://github.com/cometsh/atex/releases/tag/v0.2.0
24
134
[0.1.0]: https://github.com/cometsh/atex/releases/tag/v0.1.0
+8
-6
README.md
+8
-6
README.md
···
8
8
- [x] `at://` parsing and struct
9
9
- [x] TID codecs
10
10
- [x] XRPC client
11
-
- [ ] DID & handle resolution service with a cache
12
-
- [ ] Structs with validation for the common lexicons
13
-
- [ ] Probably codegen for doing this with other lexicons
14
-
- [ ] Extended XRPC client with support for validated inputs/outputs
15
-
- [ ] Oauth stuff
11
+
- [x] DID & handle resolution service with a cache
12
+
- [x] Macro for converting a Lexicon definition into a runtime-validation schema
13
+
- [x] Codegen to convert a directory of lexicons
14
+
- [x] Oauth stuff
15
+
- [x] Extended XRPC client with support for validated inputs/outputs
16
+
- [ ] Proper MST & CAR handling things
17
+
- [ ] Pre-transpiled libraries for popular lexicons
16
18
17
19
## Installation
18
20
···
21
23
```elixir
22
24
def deps do
23
25
[
24
-
{:atex, "~> 0.1"}
26
+
{:atex, "~> 0.7"}
25
27
]
26
28
end
27
29
```
+11
bump_builtin_lexicons.sh
+11
bump_builtin_lexicons.sh
···
1
+
#!/usr/bin/env bash
2
+
3
+
set -euo pipefail
4
+
5
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
6
+
cd "$SCRIPT_DIR"
7
+
8
+
mkdir -p ./tmp
9
+
git clone --depth 1 --single-branch https://github.com/bluesky-social/atproto.git ./tmp/atproto
10
+
mix atex.lexicons ./tmp/atproto/lexicons/com/atproto/**/*.json
11
+
rm -rf ./tmp
+10
config/runtime.exs
+10
config/runtime.exs
···
1
+
import Config
2
+
3
+
config :atex, Atex.OAuth,
4
+
# base_url: "https://comet.sh/aaaa",
5
+
base_url: "http://127.0.0.1:4000/oauth",
6
+
is_localhost: true,
7
+
scopes: ~w(transition:generic),
8
+
private_key:
9
+
"MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgyIpxhuDm0i3mPkrk6UdX4Sd9Jsv6YtAmSTza+A2nArShRANCAAQLF1GLueOBZOVnKWfrcnoDOO9NSRqH2utmfGMz+Rce18MDB7Z6CwFWjEq2UFYNBI4MI5cMI0+m+UYAmj4OZm+m",
10
+
key_id: "awooga"
+113
examples/oauth.ex
+113
examples/oauth.ex
···
1
+
defmodule ExampleOAuthPlug do
2
+
require Logger
3
+
use Plug.Router
4
+
use Plug.ErrorHandler
5
+
alias Atex.OAuth
6
+
alias Atex.XRPC
7
+
8
+
plug :put_secret_key_base
9
+
10
+
plug Plug.Session,
11
+
store: :cookie,
12
+
key: "atex-oauth",
13
+
signing_salt: "signing-salt"
14
+
15
+
plug :match
16
+
plug :dispatch
17
+
18
+
forward "/oauth", to: Atex.OAuth.Plug, init_opts: [callback: {__MODULE__, :oauth_callback, []}]
19
+
20
+
def oauth_callback(conn) do
21
+
IO.inspect(conn, label: "callback from oauth!")
22
+
23
+
conn
24
+
|> put_resp_header("Location", "/whoami")
25
+
|> resp(307, "")
26
+
|> send_resp()
27
+
end
28
+
29
+
get "/whoami" do
30
+
conn = fetch_session(conn)
31
+
32
+
case XRPC.OAuthClient.from_conn(conn) do
33
+
{:ok, client} ->
34
+
send_resp(conn, 200, "hello #{client.did}")
35
+
36
+
:error ->
37
+
send_resp(conn, 401, "Unauthorized")
38
+
end
39
+
end
40
+
41
+
get "/create-post" do
42
+
conn = fetch_session(conn)
43
+
44
+
with {:ok, client} <- XRPC.OAuthClient.from_conn(conn),
45
+
{:ok, response, client} <-
46
+
XRPC.post(client, %Com.Atproto.Repo.CreateRecord{
47
+
input: %Com.Atproto.Repo.CreateRecord.Input{
48
+
repo: client.did,
49
+
collection: "app.bsky.feed.post",
50
+
rkey: Atex.TID.now() |> to_string(),
51
+
record: %{
52
+
"$type": "app.bsky.feed.post",
53
+
text: "Hello world from atex!",
54
+
createdAt: DateTime.to_iso8601(DateTime.utc_now())
55
+
}
56
+
}
57
+
}) do
58
+
IO.inspect(response, label: "output")
59
+
60
+
send_resp(conn, 200, response.body.uri)
61
+
else
62
+
:error ->
63
+
send_resp(conn, 401, "Unauthorized")
64
+
65
+
{:error, :reauth} ->
66
+
send_resp(conn, 401, "session expired but still in your cookie")
67
+
68
+
err ->
69
+
IO.inspect(err, label: "xrpc failed")
70
+
send_resp(conn, 500, "xrpc failed")
71
+
end
72
+
end
73
+
74
+
match _ do
75
+
send_resp(conn, 404, "oops")
76
+
end
77
+
78
+
def put_secret_key_base(conn, _) do
79
+
put_in(
80
+
conn.secret_key_base,
81
+
# Don't use this in production
82
+
"5ef1078e1617463a3eb3feb9b152e76587a75a6809e0485a125b6bb7ae468f086680771f700d77ff61dfdc8d8ee8a5c7848024a41cf5ad4b6eb3115f74ce6e46"
83
+
)
84
+
end
85
+
86
+
# Error handler for OAuth exceptions
87
+
@impl Plug.ErrorHandler
88
+
def handle_errors(conn, %{kind: :error, reason: %Atex.OAuth.Error{} = error, stack: _stack}) do
89
+
status =
90
+
case error.reason do
91
+
reason
92
+
when reason in [
93
+
:missing_handle,
94
+
:invalid_handle,
95
+
:invalid_callback_request,
96
+
:issuer_mismatch
97
+
] ->
98
+
400
99
+
100
+
_ ->
101
+
500
102
+
end
103
+
104
+
conn
105
+
|> put_resp_content_type("text/plain")
106
+
|> send_resp(status, error.message)
107
+
end
108
+
109
+
# Fallback for other errors
110
+
def handle_errors(conn, %{kind: _kind, reason: _reason, stack: _stack}) do
111
+
send_resp(conn, conn.status, "Something went wrong")
112
+
end
113
+
end
+3
-3
flake.lock
+3
-3
flake.lock
···
2
2
"nodes": {
3
3
"nixpkgs": {
4
4
"locked": {
5
-
"lastModified": 1749143949,
6
-
"narHash": "sha256-QuUtALJpVrPnPeozlUG/y+oIMSLdptHxb3GK6cpSVhA=",
5
+
"lastModified": 1767379071,
6
+
"narHash": "sha256-EgE0pxsrW9jp9YFMkHL9JMXxcqi/OoumPJYwf+Okucw=",
7
7
"owner": "nixos",
8
8
"repo": "nixpkgs",
9
-
"rev": "d3d2d80a2191a73d1e86456a751b83aa13085d7d",
9
+
"rev": "fb7944c166a3b630f177938e478f0378e64ce108",
10
10
"type": "github"
11
11
},
12
12
"original": {
+16
lib/atex/application.ex
+16
lib/atex/application.ex
···
1
+
defmodule Atex.Application do
2
+
@moduledoc false
3
+
4
+
use Application
5
+
6
+
def start(_type, _args) do
7
+
children = [
8
+
Atex.IdentityResolver.Cache,
9
+
Atex.OAuth.Cache,
10
+
Atex.OAuth.SessionStore,
11
+
{Mutex, name: Atex.SessionMutex}
12
+
]
13
+
14
+
Supervisor.start_link(children, strategy: :one_for_one)
15
+
end
16
+
end
+159
lib/atex/aturi.ex
+159
lib/atex/aturi.ex
···
1
+
defmodule Atex.AtURI do
2
+
@moduledoc """
3
+
Struct and helper functions for manipulating `at://` URIs, which identify
4
+
specific records within the AT Protocol.
5
+
6
+
ATProto spec: https://atproto.com/specs/at-uri-scheme
7
+
8
+
This module only supports the restricted URI syntax used for the Lexicon
9
+
`at-uri` type, with no support for query strings or fragments. If/when the
10
+
full syntax gets widespread use, this module will expand to accomodate them.
11
+
12
+
Both URIs using DIDs and handles ("example.com") are supported.
13
+
"""
14
+
15
+
use TypedStruct
16
+
17
+
@did ~S"did:(?:plc|web):[a-zA-Z0-9._:%-]*[a-zA-Z0-9._-]"
18
+
@handle ~S"(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?"
19
+
@nsid ~S"[a-zA-Z](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?:\.[a-zA-Z](?:[a-zA-Z0-9]{0,62})?)"
20
+
21
+
@authority "(?<authority>(?:#{@did})|(?:#{@handle}))"
22
+
@collection "(?<collection>#{@nsid})"
23
+
@rkey "(?<rkey>[a-zA-Z0-9.-_:~]{1,512})"
24
+
25
+
@re ~r"^at://#{@authority}(?:/#{@collection}(?:/#{@rkey})?)?$"
26
+
27
+
typedstruct do
28
+
field :authority, String.t(), enforce: true
29
+
field :collection, String.t() | nil
30
+
field :rkey, String.t() | nil
31
+
end
32
+
33
+
@doc """
34
+
Create a new AtURI struct from a string by matching it against the regex.
35
+
36
+
Returns `{:ok, aturi}` if a valid `at://` URI is given, otherwise it will return `:error`.
37
+
38
+
## Examples
39
+
40
+
iex> Atex.AtURI.new("at://did:plc:44ybard66vv44zksje25o7dz/app.bsky.feed.post/3jwdwj2ctlk26")
41
+
{:ok, %Atex.AtURI{
42
+
rkey: "3jwdwj2ctlk26",
43
+
collection: "app.bsky.feed.post",
44
+
authority: "did:plc:44ybard66vv44zksje25o7dz"
45
+
}}
46
+
47
+
iex> Atex.AtURI.new("at:invalid/malformed")
48
+
:error
49
+
50
+
Partial URIs pointing to a collection without a record key, or even just a given authority, are also supported:
51
+
52
+
iex> Atex.AtURI.new("at://ovyerus.com/sh.comet.v0.feed.track")
53
+
{:ok, %Atex.AtURI{
54
+
rkey: nil,
55
+
collection: "sh.comet.v0.feed.track",
56
+
authority: "ovyerus.com"
57
+
}}
58
+
59
+
iex> Atex.AtURI.new("at://did:web:comet.sh")
60
+
{:ok, %Atex.AtURI{
61
+
rkey: nil,
62
+
collection: nil,
63
+
authority: "did:web:comet.sh"
64
+
}}
65
+
"""
66
+
@spec new(String.t()) :: {:ok, t()} | :error
67
+
def new(string) when is_binary(string) do
68
+
# TODO: test different ways to get a good error from regex on which part failed match?
69
+
case Regex.named_captures(@re, string) do
70
+
%{} = captures -> {:ok, from_named_captures(captures)}
71
+
nil -> :error
72
+
end
73
+
end
74
+
75
+
@doc """
76
+
The same as `new/1` but raises an `ArgumentError` if an invalid string is given.
77
+
78
+
## Examples
79
+
80
+
iex> Atex.AtURI.new!("at://did:plc:44ybard66vv44zksje25o7dz/app.bsky.feed.post/3jwdwj2ctlk26")
81
+
%Atex.AtURI{
82
+
rkey: "3jwdwj2ctlk26",
83
+
collection: "app.bsky.feed.post",
84
+
authority: "did:plc:44ybard66vv44zksje25o7dz"
85
+
}
86
+
87
+
iex> Atex.AtURI.new!("at:invalid/malformed")
88
+
** (ArgumentError) Malformed at:// URI
89
+
"""
90
+
@spec new!(String.t()) :: t()
91
+
def new!(string) when is_binary(string) do
92
+
case new(string) do
93
+
{:ok, uri} -> uri
94
+
:error -> raise ArgumentError, message: "Malformed at:// URI"
95
+
end
96
+
end
97
+
98
+
@doc """
99
+
Check if a string is a valid `at://` URI.
100
+
101
+
## Examples
102
+
103
+
iex> Atex.AtURI.match?("at://did:plc:44ybard66vv44zksje25o7dz/app.bsky.feed.post/3jwdwj2ctlk26")
104
+
true
105
+
106
+
iex> Atex.AtURI.match?("at://did:web:comet.sh")
107
+
true
108
+
109
+
iex> Atex.AtURI.match?("at://ovyerus.com/sh.comet.v0.feed.track")
110
+
true
111
+
112
+
iex> Atex.AtURI.match?("gobbledy gook")
113
+
false
114
+
"""
115
+
@spec match?(String.t()) :: boolean()
116
+
def match?(string), do: Regex.match?(@re, string)
117
+
118
+
@doc """
119
+
Format an `Atex.AtURI` to the canonical string representation.
120
+
121
+
Also available via the `String.Chars` protocol.
122
+
123
+
## Examples
124
+
125
+
iex> aturi = %Atex.AtURI{
126
+
...> rkey: "3jwdwj2ctlk26",
127
+
...> collection: "app.bsky.feed.post",
128
+
...> authority: "did:plc:44ybard66vv44zksje25o7dz"
129
+
...> }
130
+
iex> Atex.AtURI.to_string(aturi)
131
+
"at://did:plc:44ybard66vv44zksje25o7dz/app.bsky.feed.post/3jwdwj2ctlk26"
132
+
133
+
iex> aturi = %Atex.AtURI{authority: "did:web:comet.sh"}
134
+
iex> to_string(aturi)
135
+
"at://did:web:comet.sh"
136
+
"""
137
+
@spec to_string(t()) :: String.t()
138
+
def to_string(%__MODULE__{} = uri) do
139
+
"at://#{uri.authority}/#{uri.collection}/#{uri.rkey}"
140
+
|> String.trim_trailing("/")
141
+
end
142
+
143
+
defp from_named_captures(%{"authority" => authority, "collection" => "", "rkey" => ""}),
144
+
do: %__MODULE__{authority: authority}
145
+
146
+
defp from_named_captures(%{"authority" => authority, "collection" => collection, "rkey" => ""}),
147
+
do: %__MODULE__{authority: authority, collection: collection}
148
+
149
+
defp from_named_captures(%{
150
+
"authority" => authority,
151
+
"collection" => collection,
152
+
"rkey" => rkey
153
+
}),
154
+
do: %__MODULE__{authority: authority, collection: collection, rkey: rkey}
155
+
end
156
+
157
+
defimpl String.Chars, for: Atex.AtURI do
158
+
def to_string(uri), do: Atex.AtURI.to_string(uri)
159
+
end
+39
lib/atex/base32_sortable.ex
+39
lib/atex/base32_sortable.ex
···
1
+
defmodule Atex.Base32Sortable do
2
+
@moduledoc """
3
+
Codec for the base32-sortable encoding.
4
+
"""
5
+
6
+
@alphabet ~c(234567abcdefghijklmnopqrstuvwxyz)
7
+
@alphabet_len length(@alphabet)
8
+
9
+
@doc """
10
+
Encode an integer as a base32-sortable string.
11
+
"""
12
+
@spec encode(integer()) :: String.t()
13
+
def encode(int) when is_integer(int), do: do_encode(int, "")
14
+
15
+
@spec do_encode(integer(), String.t()) :: String.t()
16
+
defp do_encode(0, acc), do: acc
17
+
18
+
defp do_encode(int, acc) do
19
+
char_index = rem(int, @alphabet_len)
20
+
new_int = div(int, @alphabet_len)
21
+
22
+
# Chars are prepended to the accumulator because rem/div is pulling them off the tail of the integer.
23
+
do_encode(new_int, <<Enum.at(@alphabet, char_index)>> <> acc)
24
+
end
25
+
26
+
@doc """
27
+
Decode a base32-sortable string to an integer.
28
+
"""
29
+
@spec decode(String.t()) :: integer()
30
+
def decode(str) when is_binary(str), do: do_decode(str, 0)
31
+
32
+
@spec do_decode(String.t(), integer()) :: integer()
33
+
defp do_decode(<<>>, acc), do: acc
34
+
35
+
defp do_decode(<<char::utf8, rest::binary>>, acc) do
36
+
i = Enum.find_index(@alphabet, fn x -> x == char end)
37
+
do_decode(rest, acc * @alphabet_len + i)
38
+
end
39
+
end
+86
lib/atex/config/oauth.ex
+86
lib/atex/config/oauth.ex
···
1
+
defmodule Atex.Config.OAuth do
2
+
@moduledoc """
3
+
Configuration management for `Atex.OAuth`.
4
+
5
+
Contains all the logic for fetching configuration needed for the OAuth
6
+
module, as well as deriving useful values from them.
7
+
8
+
## Configuration
9
+
10
+
The following structure is expected in your application config:
11
+
12
+
config :atex, Atex.OAuth,
13
+
base_url: "https://example.com/oauth", # Your application's base URL, including the path `Atex.OAuth` is mounted on.
14
+
private_key: "base64-encoded-private-key", # ES256 private key
15
+
key_id: "your-key-id", # Key identifier for JWTs
16
+
scopes: ["transition:generic", "transition:email"], # Optional additional scopes
17
+
extra_redirect_uris: ["https://alternative.com/callback"], # Optional additional redirect URIs
18
+
is_localhost: false # Set to true for local development
19
+
"""
20
+
21
+
@doc """
22
+
Returns the configured public base URL for OAuth routes.
23
+
"""
24
+
@spec base_url() :: String.t()
25
+
def base_url, do: Application.fetch_env!(:atex, Atex.OAuth)[:base_url]
26
+
27
+
@doc """
28
+
Returns the configured private key as a `JOSE.JWK`.
29
+
"""
30
+
@spec get_key() :: JOSE.JWK.t()
31
+
def get_key() do
32
+
private_key =
33
+
Application.fetch_env!(:atex, Atex.OAuth)[:private_key]
34
+
|> Base.decode64!()
35
+
|> JOSE.JWK.from_der()
36
+
37
+
key_id = Application.fetch_env!(:atex, Atex.OAuth)[:key_id]
38
+
39
+
%{private_key | fields: %{"kid" => key_id}}
40
+
end
41
+
42
+
@doc """
43
+
Returns the client ID based on configuration.
44
+
45
+
If `is_localhost` is set, it'll be a string handling the "http://localhost"
46
+
special case, with the redirect URI and scopes configured, otherwise it is a
47
+
string pointing to the location of the `client-metadata.json` route.
48
+
"""
49
+
@spec client_id() :: String.t()
50
+
def client_id() do
51
+
is_localhost = Keyword.get(Application.get_env(:atex, Atex.OAuth, []), :is_localhost, false)
52
+
53
+
if is_localhost do
54
+
query =
55
+
%{redirect_uri: redirect_uri(), scope: scopes()}
56
+
|> URI.encode_query()
57
+
58
+
"http://localhost?#{query}"
59
+
else
60
+
"#{base_url()}/client-metadata.json"
61
+
end
62
+
end
63
+
64
+
@doc """
65
+
Returns the configured redirect URI.
66
+
"""
67
+
@spec redirect_uri() :: String.t()
68
+
def redirect_uri(), do: "#{base_url()}/callback"
69
+
70
+
@doc """
71
+
Returns the configured scopes joined as a space-separated string.
72
+
"""
73
+
@spec scopes() :: String.t()
74
+
def scopes() do
75
+
config_scopes = Keyword.get(Application.get_env(:atex, Atex.OAuth, []), :scopes, [])
76
+
Enum.join(["atproto" | config_scopes], " ")
77
+
end
78
+
79
+
@doc """
80
+
Returns the configured extra redirect URIs.
81
+
"""
82
+
@spec extra_redirect_uris() :: [String.t()]
83
+
def extra_redirect_uris() do
84
+
Keyword.get(Application.get_env(:atex, Atex.OAuth, []), :extra_redirect_uris, [])
85
+
end
86
+
end
+16
lib/atex/did.ex
+16
lib/atex/did.ex
···
1
+
defmodule Atex.DID do
2
+
@re ~r/^did:[a-z]+:[a-zA-Z0-9._:%-]*[a-zA-Z0-9._-]$/
3
+
@blessed_re ~r/^did:(?:plc|web):[a-zA-Z0-9._:%-]*[a-zA-Z0-9._-]$/
4
+
5
+
@spec re() :: Regex.t()
6
+
def re, do: @re
7
+
8
+
@spec match?(String.t()) :: boolean()
9
+
def match?(value), do: Regex.match?(@re, value)
10
+
11
+
@spec blessed_re() :: Regex.t()
12
+
def blessed_re, do: @blessed_re
13
+
14
+
@spec match_blessed?(String.t()) :: boolean()
15
+
def match_blessed?(value), do: Regex.match?(@blessed_re, value)
16
+
end
+9
lib/atex/handle.ex
+9
lib/atex/handle.ex
+70
lib/atex/identity_resolver/cache/ets.ex
+70
lib/atex/identity_resolver/cache/ets.ex
···
1
+
defmodule Atex.IdentityResolver.Cache.ETS do
2
+
@moduledoc """
3
+
ConCache-based implementation for Identity Resolver caching.
4
+
5
+
Stores identity information (DID and handle mappings) with a 1-hour TTL.
6
+
Uses two separate cache entries per identity to allow lookups by either DID or handle.
7
+
"""
8
+
9
+
alias Atex.IdentityResolver.Identity
10
+
@behaviour Atex.IdentityResolver.Cache
11
+
use Supervisor
12
+
13
+
@cache :atex_identities_cache
14
+
@ttl_ms :timer.hours(1)
15
+
16
+
def start_link(opts) do
17
+
Supervisor.start_link(__MODULE__, opts, name: __MODULE__)
18
+
end
19
+
20
+
@impl Supervisor
21
+
def init(_opts) do
22
+
children = [
23
+
{ConCache,
24
+
[
25
+
name: @cache,
26
+
ttl_check_interval: :timer.minutes(5),
27
+
global_ttl: @ttl_ms
28
+
]}
29
+
]
30
+
31
+
Supervisor.init(children, strategy: :one_for_one)
32
+
end
33
+
34
+
@impl Atex.IdentityResolver.Cache
35
+
@spec insert(Identity.t()) :: Identity.t()
36
+
def insert(identity) do
37
+
ConCache.put(@cache, {:did, identity.did}, identity)
38
+
ConCache.put(@cache, {:handle, identity.handle}, identity)
39
+
identity
40
+
end
41
+
42
+
@impl Atex.IdentityResolver.Cache
43
+
@spec get(String.t()) :: {:ok, Identity.t()} | {:error, atom()}
44
+
def get(identifier) do
45
+
case ConCache.get(@cache, {:did, identifier}) do
46
+
nil ->
47
+
case ConCache.get(@cache, {:handle, identifier}) do
48
+
nil -> {:error, :not_found}
49
+
identity -> {:ok, identity}
50
+
end
51
+
52
+
identity ->
53
+
{:ok, identity}
54
+
end
55
+
end
56
+
57
+
@impl Atex.IdentityResolver.Cache
58
+
@spec delete(String.t()) :: :noop | Identity.t()
59
+
def delete(identifier) do
60
+
case get(identifier) do
61
+
{:ok, identity} ->
62
+
ConCache.delete(@cache, {:did, identity.did})
63
+
ConCache.delete(@cache, {:handle, identity.handle})
64
+
identity
65
+
66
+
_ ->
67
+
:noop
68
+
end
69
+
end
70
+
end
+42
lib/atex/identity_resolver/cache.ex
+42
lib/atex/identity_resolver/cache.ex
···
1
+
defmodule Atex.IdentityResolver.Cache do
2
+
# TODO: need the following:
3
+
# did -> handle mapping
4
+
# handle -> did mapping
5
+
# did -> document mapping?
6
+
# User should be able to call a single function to fetch all info for either did and handle, including the link between them.
7
+
# Need some sort of TTL so that we can refresh as necessary
8
+
alias Atex.IdentityResolver.Identity
9
+
10
+
@cache Application.compile_env(:atex, :identity_cache, Atex.IdentityResolver.Cache.ETS)
11
+
12
+
@doc """
13
+
Add a new identity to the cache. Can also be used to update an identity that may already exist.
14
+
15
+
Returns the input `t:Atex.IdentityResolver.Identity.t/0`.
16
+
"""
17
+
@callback insert(identity :: Identity.t()) :: Identity.t()
18
+
19
+
@doc """
20
+
Retrieve an identity from the cache by DID *or* handle.
21
+
"""
22
+
@callback get(String.t()) :: {:ok, Identity.t()} | {:error, atom()}
23
+
24
+
@doc """
25
+
Delete an identity in the cache.
26
+
"""
27
+
@callback delete(String.t()) :: :noop | Identity.t()
28
+
29
+
@doc """
30
+
Get the child specification for starting the cache in a supervision tree.
31
+
"""
32
+
@callback child_spec(any()) :: Supervisor.child_spec()
33
+
34
+
defdelegate get(identifier), to: @cache
35
+
36
+
@doc false
37
+
defdelegate insert(payload), to: @cache
38
+
@doc false
39
+
defdelegate delete(snowflake), to: @cache
40
+
@doc false
41
+
defdelegate child_spec(opts), to: @cache
42
+
end
+51
lib/atex/identity_resolver/did.ex
+51
lib/atex/identity_resolver/did.ex
···
1
+
defmodule Atex.IdentityResolver.DID do
2
+
alias Atex.IdentityResolver.DIDDocument
3
+
4
+
@type resolution_result() ::
5
+
{:ok, DIDDocument.t()}
6
+
| {:error, :invalid_did_type | :invalid_did | :not_found | map() | atom() | any()}
7
+
8
+
@spec resolve(String.t()) :: resolution_result()
9
+
def resolve("did:plc:" <> _ = did), do: resolve_plc(did)
10
+
def resolve("did:web:" <> _ = did), do: resolve_web(did)
11
+
def resolve("did:" <> _), do: {:error, :invalid_did_type}
12
+
def resolve(_did), do: {:error, :invalid_did}
13
+
14
+
@spec resolve_plc(String.t()) :: resolution_result()
15
+
defp resolve_plc("did:plc:" <> _id = did) do
16
+
with {:ok, resp} when resp.status in 200..299 <-
17
+
Req.get("https://plc.directory/#{did}"),
18
+
{:ok, body} <- decode_body(resp.body),
19
+
{:ok, document} <- DIDDocument.from_json(body),
20
+
:ok <- DIDDocument.validate_for_atproto(document, did) do
21
+
{:ok, document}
22
+
else
23
+
{:ok, %{status: status}} when status in [404, 410] -> {:error, :not_found}
24
+
{:ok, %{} = resp} -> {:error, resp}
25
+
e -> e
26
+
end
27
+
end
28
+
29
+
@spec resolve_web(String.t()) :: resolution_result()
30
+
defp resolve_web("did:web:" <> domain = did) do
31
+
with {:ok, resp} when resp.status in 200..299 <-
32
+
Req.get("https://#{domain}/.well-known/did.json"),
33
+
{:ok, body} <- decode_body(resp.body),
34
+
{:ok, document} <- DIDDocument.from_json(body),
35
+
:ok <- DIDDocument.validate_for_atproto(document, did) do
36
+
{:ok, document}
37
+
else
38
+
{:ok, %{status: 404}} -> {:error, :not_found}
39
+
{:ok, %{} = resp} -> {:error, resp}
40
+
e -> e
41
+
end
42
+
end
43
+
44
+
@spec decode_body(any()) ::
45
+
{:ok, any()}
46
+
| {:error, :invalid_body | JSON.decode_error_reason()}
47
+
48
+
defp decode_body(body) when is_binary(body), do: JSON.decode(body)
49
+
defp decode_body(body) when is_map(body), do: {:ok, body}
50
+
defp decode_body(_body), do: {:error, :invalid_body}
51
+
end
+155
lib/atex/identity_resolver/did_document.ex
+155
lib/atex/identity_resolver/did_document.ex
···
1
+
defmodule Atex.IdentityResolver.DIDDocument do
2
+
@moduledoc """
3
+
Struct and schema for describing and validating a [DID document](https://github.com/w3c/did-wg/blob/main/did-explainer.md#did-documents).
4
+
"""
5
+
import Peri
6
+
use TypedStruct
7
+
8
+
defschema :schema, %{
9
+
"@context": {:required, {:list, Atex.Peri.uri()}},
10
+
id: {:required, :string},
11
+
controller: {:either, {Atex.Peri.did(), {:list, Atex.Peri.did()}}},
12
+
also_known_as: {:list, Atex.Peri.uri()},
13
+
verification_method: {:list, get_schema(:verification_method)},
14
+
authentication: {:list, {:either, {Atex.Peri.uri(), get_schema(:verification_method)}}},
15
+
service: {:list, get_schema(:service)}
16
+
}
17
+
18
+
defschema :verification_method, %{
19
+
id: {:required, Atex.Peri.uri()},
20
+
type: {:required, :string},
21
+
controller: {:required, Atex.Peri.did()},
22
+
public_key_multibase: :string,
23
+
public_key_jwk: :map
24
+
}
25
+
26
+
defschema :service, %{
27
+
id: {:required, Atex.Peri.uri()},
28
+
type: {:required, {:either, {:string, {:list, :string}}}},
29
+
service_endpoint:
30
+
{:required,
31
+
{:oneof,
32
+
[
33
+
Atex.Peri.uri(),
34
+
{:map, Atex.Peri.uri()},
35
+
{:list, {:either, {Atex.Peri.uri(), {:map, Atex.Peri.uri()}}}}
36
+
]}}
37
+
}
38
+
39
+
@type verification_method() :: %{
40
+
required(:id) => String.t(),
41
+
required(:type) => String.t(),
42
+
required(:controller) => String.t(),
43
+
optional(:public_key_multibase) => String.t(),
44
+
optional(:public_key_jwk) => map()
45
+
}
46
+
47
+
@type service() :: %{
48
+
required(:id) => String.t(),
49
+
required(:type) => String.t() | list(String.t()),
50
+
required(:service_endpoint) =>
51
+
String.t()
52
+
| %{String.t() => String.t()}
53
+
| list(String.t() | %{String.t() => String.t()})
54
+
}
55
+
56
+
typedstruct do
57
+
field :"@context", list(String.t()), enforce: true
58
+
field :id, String.t(), enforce: true
59
+
field :controller, String.t() | list(String.t())
60
+
field :also_known_as, list(String.t())
61
+
field :verification_method, list(verification_method())
62
+
field :authentication, list(String.t() | verification_method())
63
+
field :service, list(service())
64
+
end
65
+
66
+
def new(params), do: struct(__MODULE__, params)
67
+
68
+
@spec from_json(map()) :: {:ok, t()} | {:error, Peri.Error.t()}
69
+
def from_json(%{} = map) do
70
+
map
71
+
|> Recase.Enumerable.convert_keys(&Recase.to_snake/1)
72
+
|> schema()
73
+
|> case do
74
+
# {:ok, params} -> {:ok, struct(__MODULE__, params)}
75
+
{:ok, params} -> {:ok, new(params)}
76
+
e -> e
77
+
end
78
+
end
79
+
80
+
@spec validate_for_atproto(t(), String.t()) :: any()
81
+
def validate_for_atproto(%__MODULE__{} = doc, did) do
82
+
# TODO: make sure this is ok
83
+
id_matches = doc.id == did
84
+
85
+
valid_signing_key =
86
+
Enum.any?(doc.verification_method, fn method ->
87
+
String.ends_with?(method.id, "#atproto") and method.controller == did
88
+
end)
89
+
90
+
valid_pds_service =
91
+
Enum.any?(doc.service, fn service ->
92
+
String.ends_with?(service.id, "#atproto_pds") and
93
+
service.type == "AtprotoPersonalDataServer" and
94
+
valid_pds_endpoint?(service.service_endpoint)
95
+
end)
96
+
97
+
case {id_matches, valid_signing_key, valid_pds_service} do
98
+
{true, true, true} -> :ok
99
+
{false, _, _} -> {:error, :id_mismatch}
100
+
{_, false, _} -> {:error, :no_signing_key}
101
+
{_, _, false} -> {:error, :invalid_pds}
102
+
end
103
+
end
104
+
105
+
@doc """
106
+
Get the associated ATProto handle in the DID document.
107
+
108
+
ATProto dictates that only the first valid handle is to be used, so this
109
+
follows that rule.
110
+
111
+
> #### Note {: .info}
112
+
>
113
+
> While DID documents are fairly authoritative, you need to make sure to
114
+
> validate the handle bidirectionally. See
115
+
> `Atex.IdentityResolver.Handle.resolve/2`.
116
+
"""
117
+
@spec get_atproto_handle(t()) :: String.t() | nil
118
+
def get_atproto_handle(%__MODULE__{also_known_as: nil}), do: nil
119
+
120
+
def get_atproto_handle(%__MODULE__{} = doc) do
121
+
Enum.find_value(doc.also_known_as, fn
122
+
# TODO: make sure no path or other URI parts
123
+
"at://" <> handle -> handle
124
+
_ -> nil
125
+
end)
126
+
end
127
+
128
+
@spec get_pds_endpoint(t()) :: String.t() | nil
129
+
def get_pds_endpoint(%__MODULE__{} = doc) do
130
+
doc.service
131
+
|> Enum.find(fn
132
+
%{id: "#atproto_pds", type: "AtprotoPersonalDataServer"} -> true
133
+
_ -> false
134
+
end)
135
+
|> case do
136
+
nil -> nil
137
+
pds -> pds.service_endpoint
138
+
end
139
+
end
140
+
141
+
defp valid_pds_endpoint?(endpoint) do
142
+
case URI.new(endpoint) do
143
+
{:ok, uri} ->
144
+
is_plain_uri =
145
+
uri
146
+
|> Map.from_struct()
147
+
|> Enum.all?(fn
148
+
{key, value} when key in [:userinfo, :path, :query, :fragment] -> is_nil(value)
149
+
_ -> true
150
+
end)
151
+
152
+
uri.scheme in ["https", "http"] and is_plain_uri
153
+
end
154
+
end
155
+
end
+74
lib/atex/identity_resolver/handle.ex
+74
lib/atex/identity_resolver/handle.ex
···
1
+
defmodule Atex.IdentityResolver.Handle do
2
+
@type strategy() :: :dns_first | :http_first | :race | :both
3
+
4
+
@spec resolve(String.t(), strategy()) ::
5
+
{:ok, String.t()} | :error | {:error, :ambiguous_handle}
6
+
def resolve(handle, strategy)
7
+
8
+
def resolve(handle, :dns_first) do
9
+
case resolve_via_dns(handle) do
10
+
:error -> resolve_via_http(handle)
11
+
ok -> ok
12
+
end
13
+
end
14
+
15
+
def resolve(handle, :http_first) do
16
+
case resolve_via_http(handle) do
17
+
:error -> resolve_via_dns(handle)
18
+
ok -> ok
19
+
end
20
+
end
21
+
22
+
def resolve(handle, :race) do
23
+
[&resolve_via_dns/1, &resolve_via_http/1]
24
+
|> Task.async_stream(& &1.(handle), max_concurrency: 2, ordered: false)
25
+
|> Stream.filter(&match?({:ok, {:ok, _}}, &1))
26
+
|> Enum.at(0)
27
+
end
28
+
29
+
def resolve(handle, :both) do
30
+
case Task.await_many([
31
+
Task.async(fn -> resolve_via_dns(handle) end),
32
+
Task.async(fn -> resolve_via_http(handle) end)
33
+
]) do
34
+
[{:ok, dns_did}, {:ok, http_did}] ->
35
+
if dns_did && http_did && dns_did != http_did do
36
+
{:error, :ambiguous_handle}
37
+
else
38
+
{:ok, dns_did}
39
+
end
40
+
41
+
_ ->
42
+
:error
43
+
end
44
+
end
45
+
46
+
@spec resolve_via_dns(String.t()) :: {:ok, String.t()} | :error
47
+
defp resolve_via_dns(handle) do
48
+
with ["did=" <> did] <- query_dns("_atproto.#{handle}", :txt),
49
+
"did:" <> _ <- did do
50
+
{:ok, did}
51
+
else
52
+
_ -> :error
53
+
end
54
+
end
55
+
56
+
@spec resolve_via_http(String.t()) :: {:ok, String.t()} | :error
57
+
defp resolve_via_http(handle) do
58
+
case Req.get("https://#{handle}/.well-known/atproto-did") do
59
+
{:ok, %{body: "did:" <> _ = did}} -> {:ok, did}
60
+
_ -> :error
61
+
end
62
+
end
63
+
64
+
@spec query_dns(String.t(), :inet_res.dns_rr_type()) :: list(String.t() | list(String.t()))
65
+
defp query_dns(domain, type) do
66
+
domain
67
+
|> String.to_charlist()
68
+
|> :inet_res.lookup(:in, type)
69
+
|> Enum.map(fn
70
+
[result] -> to_string(result)
71
+
result -> result
72
+
end)
73
+
end
74
+
end
+25
lib/atex/identity_resolver/identity.ex
+25
lib/atex/identity_resolver/identity.ex
···
1
+
defmodule Atex.IdentityResolver.Identity do
2
+
use TypedStruct
3
+
4
+
@typedoc """
5
+
The controlling DID for an identity.
6
+
"""
7
+
@type did() :: String.t()
8
+
@typedoc """
9
+
The human-readable handle for an identity. Can be missing.
10
+
"""
11
+
@type handle() :: String.t() | nil
12
+
@typedoc """
13
+
The resolved DID document for an identity.
14
+
"""
15
+
@type document() :: Atex.IdentityResolver.DIDDocument.t()
16
+
17
+
typedstruct do
18
+
field :did, did(), enforce: true
19
+
field :handle, handle()
20
+
field :document, document(), enforce: true
21
+
end
22
+
23
+
@spec new(did(), handle(), document()) :: t()
24
+
def new(did, handle, document), do: %__MODULE__{did: did, handle: handle, document: document}
25
+
end
+56
lib/atex/identity_resolver.ex
+56
lib/atex/identity_resolver.ex
···
1
+
defmodule Atex.IdentityResolver do
2
+
alias Atex.IdentityResolver.{Cache, DID, DIDDocument, Handle, Identity}
3
+
4
+
@handle_strategy Application.compile_env(:atex, :handle_resolver_strategy, :dns_first)
5
+
@type options() :: {:skip_cache, boolean()}
6
+
7
+
# TODO: simplify errors
8
+
9
+
@spec resolve(String.t(), list(options())) :: {:ok, Identity.t()} | {:error, any()}
10
+
def resolve(identifier, opts \\ []) do
11
+
opts = Keyword.validate!(opts, skip_cache: false)
12
+
skip_cache = Keyword.get(opts, :skip_cache)
13
+
14
+
cache_result = if skip_cache, do: {:error, :not_found}, else: Cache.get(identifier)
15
+
16
+
# If cache fetch succeeds, then the ok tuple will be retuned by the default `with` behaviour
17
+
with {:error, :not_found} <- cache_result,
18
+
{:ok, identity} <- do_resolve(identifier),
19
+
identity <- Cache.insert(identity) do
20
+
{:ok, identity}
21
+
end
22
+
end
23
+
24
+
@spec do_resolve(identity :: String.t()) ::
25
+
{:ok, Identity.t()}
26
+
| {:error, :handle_mismatch}
27
+
| {:error, any()}
28
+
defp do_resolve("did:" <> _ = did) do
29
+
with {:ok, document} <- DID.resolve(did),
30
+
:ok <- DIDDocument.validate_for_atproto(document, did) do
31
+
with handle when not is_nil(handle) <- DIDDocument.get_atproto_handle(document),
32
+
{:ok, handle_did} <- Handle.resolve(handle, @handle_strategy),
33
+
true <- handle_did == did do
34
+
{:ok, Identity.new(did, handle, document)}
35
+
else
36
+
# Not having a handle, while a little un-ergonomic, is totally valid.
37
+
nil -> {:ok, Identity.new(did, nil, document)}
38
+
false -> {:error, :handle_mismatch}
39
+
e -> e
40
+
end
41
+
end
42
+
end
43
+
44
+
defp do_resolve(handle) do
45
+
with {:ok, did} <- Handle.resolve(handle, @handle_strategy),
46
+
{:ok, document} <- DID.resolve(did),
47
+
did_handle when not is_nil(handle) <- DIDDocument.get_atproto_handle(document),
48
+
true <- did_handle == handle do
49
+
{:ok, Identity.new(did, handle, document)}
50
+
else
51
+
nil -> {:error, :handle_mismatch}
52
+
false -> {:error, :handle_mismatch}
53
+
e -> e
54
+
end
55
+
end
56
+
end
+264
lib/atex/lexicon/schema.ex
+264
lib/atex/lexicon/schema.ex
···
1
+
defmodule Atex.Lexicon.Schema do
2
+
import Peri
3
+
4
+
defschema :lexicon, %{
5
+
lexicon: {:required, {:literal, 1}},
6
+
id: {:required, {:string, {:regex, Atex.NSID.re()}}},
7
+
revision: {:integer, {:gte, 0}},
8
+
description: :string,
9
+
defs: {
10
+
:required,
11
+
{:schema,
12
+
%{
13
+
main:
14
+
{:oneof,
15
+
[
16
+
get_schema(:record),
17
+
get_schema(:query),
18
+
get_schema(:procedure),
19
+
get_schema(:subscription),
20
+
get_schema(:user_types)
21
+
]}
22
+
}, {:additional_keys, get_schema(:user_types)}}
23
+
}
24
+
}
25
+
26
+
defschema :record, %{
27
+
type: {:required, {:literal, "record"}},
28
+
description: :string,
29
+
# TODO: constraint
30
+
key: {:required, :string},
31
+
record: {:required, get_schema(:object)}
32
+
}
33
+
34
+
defschema :query, %{
35
+
type: {:required, {:literal, "query"}},
36
+
description: :string,
37
+
parameters: get_schema(:parameters),
38
+
output: get_schema(:body),
39
+
errors: {:list, get_schema(:error)}
40
+
}
41
+
42
+
defschema :procedure, %{
43
+
type: {:required, {:literal, "procedure"}},
44
+
description: :string,
45
+
parameters: get_schema(:parameters),
46
+
input: get_schema(:body),
47
+
output: get_schema(:body),
48
+
errors: {:list, get_schema(:error)}
49
+
}
50
+
51
+
defschema :subscription, %{
52
+
type: {:required, {:literal, "subscription"}},
53
+
description: :string,
54
+
parameters: get_schema(:parameters),
55
+
message: %{
56
+
description: :string,
57
+
schema: {:oneof, [get_schema(:object), get_schema(:ref_variant)]}
58
+
},
59
+
errors: {:list, get_schema(:error)}
60
+
}
61
+
62
+
defschema :parameters, %{
63
+
type: {:required, {:literal, "params"}},
64
+
description: :string,
65
+
# required: {{:list, :string}, {:default, []}},
66
+
required: {:list, :string},
67
+
properties:
68
+
{:required, {:map, {:either, {get_schema(:primitive), get_schema(:primitive_array)}}}}
69
+
}
70
+
71
+
defschema :body, %{
72
+
description: :string,
73
+
encoding: {:required, :string},
74
+
schema: {:oneof, [get_schema(:object), get_schema(:ref_variant)]}
75
+
}
76
+
77
+
defschema :error, %{
78
+
name: {:required, :string},
79
+
description: :string
80
+
}
81
+
82
+
defschema :user_types,
83
+
{:oneof,
84
+
[
85
+
get_schema(:blob),
86
+
get_schema(:array),
87
+
get_schema(:token),
88
+
get_schema(:object),
89
+
get_schema(:boolean),
90
+
get_schema(:integer),
91
+
get_schema(:string),
92
+
get_schema(:bytes),
93
+
get_schema(:cid_link),
94
+
get_schema(:unknown)
95
+
]}
96
+
97
+
# General types
98
+
99
+
@ref_value {:string,
100
+
{
101
+
:regex,
102
+
# TODO: minlength 1
103
+
~r/^(?=.)(?:[a-zA-Z](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?:\.[a-zA-Z](?:[a-zA-Z]{0,61}[a-zA-Z])?))?(?:#[a-zA-Z](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)?$/
104
+
}}
105
+
106
+
@positive_int {:integer, {:gte, 0}}
107
+
@nonzero_positive_int {:integer, {:gt, 0}}
108
+
109
+
defschema :ref_variant, {:oneof, [get_schema(:ref), get_schema(:ref_union)]}
110
+
111
+
defschema :ref, %{
112
+
type: {:required, {:literal, "ref"}},
113
+
description: :string,
114
+
ref: {:required, @ref_value}
115
+
}
116
+
117
+
defschema :ref_union, %{
118
+
type: {:required, {:literal, "union"}},
119
+
description: :string,
120
+
refs: {:required, {:list, @ref_value}}
121
+
}
122
+
123
+
defschema :array, %{
124
+
type: {:required, {:literal, "array"}},
125
+
description: :string,
126
+
items:
127
+
{:required,
128
+
{:oneof,
129
+
[get_schema(:primitive), get_schema(:ipld), get_schema(:blob), get_schema(:ref_variant)]}},
130
+
maxLength: @positive_int,
131
+
minLength: @positive_int
132
+
}
133
+
134
+
defschema :primitive_array, %{
135
+
type: {:required, {:literal, "array"}},
136
+
description: :string,
137
+
items: {:required, get_schema(:primitive)},
138
+
maxLength: @positive_int,
139
+
minLength: @positive_int
140
+
}
141
+
142
+
defschema :object, %{
143
+
type: {:required, {:literal, "object"}},
144
+
description: :string,
145
+
# required: {{:list, :string}, {:default, []}},
146
+
# nullable: {{:list, :string}, {:default, []}},
147
+
required: {:list, :string},
148
+
nullable: {:list, :string},
149
+
properties:
150
+
{:required,
151
+
{:map,
152
+
{:oneof,
153
+
[
154
+
get_schema(:ref_variant),
155
+
get_schema(:ipld),
156
+
get_schema(:array),
157
+
get_schema(:blob),
158
+
get_schema(:primitive)
159
+
]}}}
160
+
}
161
+
162
+
defschema :primitive,
163
+
{:oneof,
164
+
[
165
+
get_schema(:boolean),
166
+
get_schema(:integer),
167
+
get_schema(:string),
168
+
get_schema(:unknown)
169
+
]}
170
+
171
+
defschema :ipld, {:oneof, [get_schema(:bytes), get_schema(:cid_link)]}
172
+
173
+
defschema :blob, %{
174
+
type: {:required, {:literal, "blob"}},
175
+
description: :string,
176
+
accept: {:list, :string},
177
+
maxSize: @positive_int
178
+
}
179
+
180
+
defschema :boolean, %{
181
+
type: {:required, {:literal, "boolean"}},
182
+
description: :string,
183
+
default: :boolean,
184
+
const: :boolean
185
+
}
186
+
187
+
defschema :bytes, %{
188
+
type: {:required, {:literal, "bytes"}},
189
+
description: :string,
190
+
maxLength: @positive_int,
191
+
minLength: @positive_int
192
+
}
193
+
194
+
defschema :cid_link, %{
195
+
type: {:required, {:literal, "cid-link"}},
196
+
description: :string
197
+
}
198
+
199
+
@string_type {:required, {:literal, "string"}}
200
+
201
+
defschema :string,
202
+
{:either,
203
+
{
204
+
# Formatted
205
+
%{
206
+
type: @string_type,
207
+
format:
208
+
{:required,
209
+
{:enum,
210
+
[
211
+
"at-identifier",
212
+
"at-uri",
213
+
"cid",
214
+
"datetime",
215
+
"did",
216
+
"handle",
217
+
"language",
218
+
"nsid",
219
+
"record-key",
220
+
"tid",
221
+
"uri"
222
+
]}},
223
+
description: :string,
224
+
default: :string,
225
+
const: :string,
226
+
enum: {:list, :string},
227
+
knownValues: {:list, :string}
228
+
},
229
+
# Unformatted
230
+
%{
231
+
type: @string_type,
232
+
description: :string,
233
+
default: :string,
234
+
const: :string,
235
+
enum: {:list, :string},
236
+
knownValues: {:list, :string},
237
+
format: {:literal, nil},
238
+
maxLength: @nonzero_positive_int,
239
+
minLength: @nonzero_positive_int,
240
+
maxGraphemes: @nonzero_positive_int,
241
+
minGraphemes: @nonzero_positive_int
242
+
}
243
+
}}
244
+
245
+
defschema :integer, %{
246
+
type: {:required, {:literal, "integer"}},
247
+
description: :string,
248
+
default: @positive_int,
249
+
const: @positive_int,
250
+
enum: {:list, @positive_int},
251
+
maximum: @positive_int,
252
+
minimum: @positive_int
253
+
}
254
+
255
+
defschema :token, %{
256
+
type: {:required, {:literal, "token"}},
257
+
description: :string
258
+
}
259
+
260
+
defschema :unknown, %{
261
+
type: {:required, {:literal, "unknown"}},
262
+
description: :string
263
+
}
264
+
end
+52
lib/atex/lexicon/validators/array.ex
+52
lib/atex/lexicon/validators/array.ex
···
1
+
defmodule Atex.Lexicon.Validators.Array do
2
+
@type option() :: {:min_length, non_neg_integer()} | {:max_length, non_neg_integer()}
3
+
4
+
@option_keys [:min_length, :max_length]
5
+
6
+
# Needs type input
7
+
@spec validate(term(), Peri.schema_def(), list(option())) :: Peri.validation_result()
8
+
def validate(value, inner_type, options) when is_list(value) do
9
+
# TODO: validate inner_type with Peri to make sure it's correct?
10
+
11
+
options
12
+
|> Keyword.validate!(min_length: nil, max_length: nil)
13
+
|> Stream.map(&validate_option(value, &1))
14
+
|> Enum.find(:ok, fn x -> x != :ok end)
15
+
|> case do
16
+
:ok ->
17
+
value
18
+
|> Stream.map(&Peri.validate(inner_type, &1))
19
+
|> Enum.find({:ok, nil}, fn
20
+
{:ok, _} -> false
21
+
{:error, _} -> true
22
+
end)
23
+
|> case do
24
+
{:ok, _} -> :ok
25
+
e -> e
26
+
end
27
+
28
+
e ->
29
+
e
30
+
end
31
+
end
32
+
33
+
def validate(_inner_type, value, _options),
34
+
do: {:error, "expected type of `array`, received #{value}", [expected: :array, actual: value]}
35
+
36
+
@spec validate_option(list(), option()) :: Peri.validation_result()
37
+
defp validate_option(value, option)
38
+
39
+
defp validate_option(_value, {option, nil}) when option in @option_keys, do: :ok
40
+
41
+
defp validate_option(value, {:min_length, expected}) when length(value) >= expected,
42
+
do: :ok
43
+
44
+
defp validate_option(value, {:min_length, expected}) when length(value) < expected,
45
+
do: {:error, "should have a minimum length of #{expected}", [length: expected]}
46
+
47
+
defp validate_option(value, {:max_length, expected}) when length(value) <= expected,
48
+
do: :ok
49
+
50
+
defp validate_option(value, {:max_length, expected}) when length(value) > expected,
51
+
do: {:error, "should have a maximum length of #{expected}", [length: expected]}
52
+
end
+32
lib/atex/lexicon/validators/bytes.ex
+32
lib/atex/lexicon/validators/bytes.ex
···
1
+
defmodule Atex.Lexicon.Validators.Bytes do
2
+
@type option() :: {:min_length, pos_integer()} | {:max_length, pos_integer()}
3
+
4
+
@option_keys [:min_length, :max_length]
5
+
6
+
@spec validate(term(), list(option())) :: Peri.validation_result()
7
+
def validate(value, options) when is_binary(value) do
8
+
case Base.decode64(value, padding: false) do
9
+
{:ok, bytes} ->
10
+
options
11
+
|> Keyword.validate!(min_length: nil, max_length: nil)
12
+
|> Stream.map(&validate_option(bytes, &1))
13
+
|> Enum.find(:ok, fn x -> x !== :ok end)
14
+
15
+
:error ->
16
+
{:error, "expected valid base64 encoded bytes", []}
17
+
end
18
+
end
19
+
20
+
def validate(value, _options),
21
+
do:
22
+
{:error, "expected valid base64 encoded bytes, received #{value}",
23
+
[expected: :bytes, actual: value]}
24
+
25
+
defp validate_option(_value, {option, nil}) when option in @option_keys, do: :ok
26
+
27
+
defp validate_option(value, {:min_length, expected}) when byte_size(value) < expected,
28
+
do: {:error, "should have a minimum byte length of #{expected}", [length: expected]}
29
+
30
+
defp validate_option(value, {:max_length, expected}) when byte_size(value) <= expected,
31
+
do: :ok
32
+
end
+38
lib/atex/lexicon/validators/integer.ex
+38
lib/atex/lexicon/validators/integer.ex
···
1
+
defmodule Atex.Lexicon.Validators.Integer do
2
+
@type option() ::
3
+
{:minimum, integer()}
4
+
| {:maximum, integer()}
5
+
6
+
@option_keys [:minimum, :maximum]
7
+
8
+
@spec validate(term(), list(option())) :: Peri.validation_result()
9
+
def validate(value, options) when is_integer(value) do
10
+
options
11
+
|> Keyword.validate!(
12
+
minimum: nil,
13
+
maximum: nil
14
+
)
15
+
|> Stream.map(&validate_option(value, &1))
16
+
|> Enum.find(:ok, fn x -> x != :ok end)
17
+
end
18
+
19
+
def validate(value, _options),
20
+
do:
21
+
{:error, "expected type of `integer`, received #{value}",
22
+
[expected: :integer, actual: value]}
23
+
24
+
@spec validate_option(integer(), option()) :: Peri.validation_result()
25
+
defp validate_option(value, option)
26
+
27
+
defp validate_option(_value, {option, nil}) when option in @option_keys, do: :ok
28
+
29
+
defp validate_option(value, {:minimum, expected}) when value >= expected, do: :ok
30
+
31
+
defp validate_option(value, {:minimum, expected}) when value < expected,
32
+
do: {:error, "", [value: expected]}
33
+
34
+
defp validate_option(value, {:maximum, expected}) when value <= expected, do: :ok
35
+
36
+
defp validate_option(value, {:maximum, expected}) when value > expected,
37
+
do: {:error, "", [value: expected]}
38
+
end
+134
lib/atex/lexicon/validators/string.ex
+134
lib/atex/lexicon/validators/string.ex
···
1
+
defmodule Atex.Lexicon.Validators.String do
2
+
alias Atex.Lexicon.Validators
3
+
4
+
@type option() ::
5
+
{:format, String.t()}
6
+
| {:min_length, non_neg_integer()}
7
+
| {:max_length, non_neg_integer()}
8
+
| {:min_graphemes, non_neg_integer()}
9
+
| {:max_graphemes, non_neg_integer()}
10
+
11
+
@option_keys [
12
+
:format,
13
+
:min_length,
14
+
:max_length,
15
+
:min_graphemes,
16
+
:max_graphemes
17
+
]
18
+
19
+
@record_key_re ~r"^[a-zA-Z0-9.-_:~]$"
20
+
21
+
@spec validate(term(), list(option())) :: Peri.validation_result()
22
+
def validate(value, options) when is_binary(value) do
23
+
options
24
+
|> Keyword.validate!(
25
+
format: nil,
26
+
min_length: nil,
27
+
max_length: nil,
28
+
min_graphemes: nil,
29
+
max_graphemes: nil
30
+
)
31
+
# Stream so we early exit at the first error.
32
+
|> Stream.map(&validate_option(value, &1))
33
+
|> Enum.find(:ok, fn x -> x != :ok end)
34
+
end
35
+
36
+
def validate(value, _options),
37
+
do:
38
+
{:error, "expected type of `string`, received #{value}", [expected: :string, actual: value]}
39
+
40
+
@spec validate_option(String.t(), option()) :: Peri.validation_result()
41
+
defp validate_option(value, option)
42
+
43
+
defp validate_option(_value, {option, nil}) when option in @option_keys, do: :ok
44
+
45
+
defp validate_option(value, {:format, "at-identifier"}),
46
+
do:
47
+
Validators.boolean_validate(
48
+
Atex.DID.match?(value) or Atex.Handle.match?(value),
49
+
"should be a valid DID or handle"
50
+
)
51
+
52
+
defp validate_option(value, {:format, "at-uri"}),
53
+
do: Validators.boolean_validate(Atex.AtURI.match?(value), "should be a valid at:// URI")
54
+
55
+
defp validate_option(value, {:format, "cid"}) do
56
+
# TODO: is there a regex provided by the lexicon docs/somewhere?
57
+
try do
58
+
Multiformats.CID.decode(value)
59
+
:ok
60
+
rescue
61
+
_ -> {:error, "should be a valid CID", []}
62
+
end
63
+
end
64
+
65
+
defp validate_option(value, {:format, "datetime"}) do
66
+
# NaiveDateTime is used over DateTime because the result isn't actually
67
+
# being used, so we don't need to include a calendar library just for this.
68
+
case NaiveDateTime.from_iso8601(value) do
69
+
{:ok, _} -> :ok
70
+
{:error, _} -> {:error, "should be a valid datetime", []}
71
+
end
72
+
end
73
+
74
+
defp validate_option(value, {:format, "did"}),
75
+
do: Validators.boolean_validate(Atex.DID.match?(value), "should be a valid DID")
76
+
77
+
defp validate_option(value, {:format, "handle"}),
78
+
do: Validators.boolean_validate(Atex.Handle.match?(value), "should be a valid handle")
79
+
80
+
defp validate_option(value, {:format, "nsid"}),
81
+
do: Validators.boolean_validate(Atex.NSID.match?(value), "should be a valid NSID")
82
+
83
+
defp validate_option(value, {:format, "tid"}),
84
+
do: Validators.boolean_validate(Atex.TID.match?(value), "should be a valid TID")
85
+
86
+
defp validate_option(value, {:format, "record-key"}),
87
+
do:
88
+
Validators.boolean_validate(
89
+
Regex.match?(@record_key_re, value),
90
+
"should be a valid record key"
91
+
)
92
+
93
+
defp validate_option(value, {:format, "uri"}) do
94
+
case URI.new(value) do
95
+
{:ok, _} -> :ok
96
+
{:error, _} -> {:error, "should be a valid URI", []}
97
+
end
98
+
end
99
+
100
+
defp validate_option(value, {:format, "language"}) do
101
+
case Cldr.LanguageTag.parse(value) do
102
+
{:ok, _} -> :ok
103
+
{:error, _} -> {:error, "should be a valid BCP 47 language tag", []}
104
+
end
105
+
end
106
+
107
+
defp validate_option(value, {:min_length, expected}) when byte_size(value) >= expected,
108
+
do: :ok
109
+
110
+
defp validate_option(value, {:min_length, expected}) when byte_size(value) < expected,
111
+
do: {:error, "should have a minimum byte length of #{expected}", [length: expected]}
112
+
113
+
defp validate_option(value, {:max_length, expected}) when byte_size(value) <= expected,
114
+
do: :ok
115
+
116
+
defp validate_option(value, {:max_length, expected}) when byte_size(value) > expected,
117
+
do: {:error, "should have a maximum byte length of #{expected}", [length: expected]}
118
+
119
+
defp validate_option(value, {:min_graphemes, expected}),
120
+
do:
121
+
Validators.boolean_validate(
122
+
String.length(value) >= expected,
123
+
"should have a minimum length of #{expected}",
124
+
length: expected
125
+
)
126
+
127
+
defp validate_option(value, {:max_graphemes, expected}),
128
+
do:
129
+
Validators.boolean_validate(
130
+
String.length(value) <= expected,
131
+
"should have a maximum length of #{expected}",
132
+
length: expected
133
+
)
134
+
end
+106
lib/atex/lexicon/validators.ex
+106
lib/atex/lexicon/validators.ex
···
1
+
defmodule Atex.Lexicon.Validators do
2
+
alias Atex.Lexicon.Validators
3
+
4
+
@type blob_option() :: {:accept, list(String.t())} | {:max_size, pos_integer()}
5
+
6
+
@type blob() ::
7
+
%{
8
+
"$type": String.t(),
9
+
ref: %{"$link": String.t()},
10
+
mimeType: String.t(),
11
+
size: integer()
12
+
}
13
+
| %{
14
+
cid: String.t(),
15
+
mimeType: String.t()
16
+
}
17
+
18
+
@type cid_link() :: %{"$link": String.t()}
19
+
20
+
@type bytes() :: %{"$bytes": binary()}
21
+
22
+
@spec string(list(Validators.String.option())) :: Peri.custom_def()
23
+
def string(options \\ []), do: {:custom, {Validators.String, :validate, [options]}}
24
+
25
+
@spec integer(list(Validators.Integer.option())) :: Peri.custom_def()
26
+
def integer(options \\ []), do: {:custom, {Validators.Integer, :validate, [options]}}
27
+
28
+
@spec array(Peri.schema_def(), list(Validators.Array.option())) :: Peri.custom_def()
29
+
def array(inner_type, options \\ []) do
30
+
{:custom, {Validators.Array, :validate, [inner_type, options]}}
31
+
end
32
+
33
+
@spec blob(list(blob_option())) :: Peri.schema_def()
34
+
def blob(options \\ []) do
35
+
options = Keyword.validate!(options, accept: nil, max_size: nil)
36
+
accept = Keyword.get(options, :accept)
37
+
max_size = Keyword.get(options, :max_size)
38
+
39
+
mime_type =
40
+
{:required,
41
+
if(accept,
42
+
do: {:string, {:regex, strings_to_re(accept)}},
43
+
else: {:string, {:regex, ~r"^.+/.+$"}}
44
+
)}
45
+
46
+
{
47
+
:either,
48
+
{
49
+
# Newer blobs
50
+
%{
51
+
"$type": {:required, {:literal, "blob"}},
52
+
ref: {:required, %{"$link": {:required, :string}}},
53
+
mimeType: mime_type,
54
+
size: {:required, if(max_size != nil, do: {:integer, {:lte, max_size}}, else: :integer)}
55
+
},
56
+
# Old deprecated blobs
57
+
%{
58
+
cid: {:required, :string},
59
+
mimeType: mime_type
60
+
}
61
+
}
62
+
}
63
+
end
64
+
65
+
@spec bytes(list(Validators.Bytes.option())) :: Peri.schema()
66
+
def bytes(options \\ []) do
67
+
options = Keyword.validate!(options, min_length: nil, max_length: nil)
68
+
69
+
%{
70
+
"$bytes":
71
+
{:required,
72
+
{{:custom, {Validators.Bytes, :validate, [options]}}, {:transform, &Base.decode64!/1}}}
73
+
}
74
+
end
75
+
76
+
# TODO: see what atcute validators expect
77
+
# TODO: cid validation?
78
+
def cid_link() do
79
+
%{
80
+
"$link": {:required, :string}
81
+
}
82
+
end
83
+
84
+
@spec lazy_ref(module(), atom()) :: Peri.schema()
85
+
def lazy_ref(module, schema_name) do
86
+
{:custom, {module, schema_name, []}}
87
+
end
88
+
89
+
@spec boolean_validate(boolean(), String.t(), keyword() | map()) ::
90
+
Peri.validation_result()
91
+
def boolean_validate(success?, error_message, context \\ []) do
92
+
if success? do
93
+
:ok
94
+
else
95
+
{:error, error_message, context}
96
+
end
97
+
end
98
+
99
+
@spec strings_to_re(list(String.t())) :: Regex.t()
100
+
defp strings_to_re(strings) do
101
+
strings
102
+
|> Enum.map(&String.replace(&1, "*", ".+"))
103
+
|> Enum.join("|")
104
+
|> then(&~r/^(#{&1})$/)
105
+
end
106
+
end
+669
lib/atex/lexicon.ex
+669
lib/atex/lexicon.ex
···
1
+
defmodule Atex.Lexicon do
2
+
alias Atex.Lexicon.Validators
3
+
4
+
defmacro __using__(_opts) do
5
+
quote do
6
+
import Atex.Lexicon
7
+
import Atex.Lexicon.Validators
8
+
import Peri
9
+
end
10
+
end
11
+
12
+
@doc """
13
+
Defines a lexicon module from a JSON lexicon definition.
14
+
15
+
The `deflexicon` macro processes the provided lexicon map (typically loaded
16
+
from a JSON file) and generates:
17
+
18
+
- **Typespecs** for each definition, exposing a `t/0` type for the main
19
+
definition and named types for any additional definitions.
20
+
- **`Peri` schemas** via `defschema/2` for runtime validation of data.
21
+
- **Structs** for object and record definitions, with `@enforce_keys` ensuring
22
+
required fields are present.
23
+
- For **queries** and **procedures**, it creates structs for `params`,
24
+
`input`, and `output` when those sections exist in the lexicon. It also
25
+
generates a topโlevel struct that aggregates `params` and `input` (when
26
+
applicable); this struct is used by the XRPC client to locate the
27
+
appropriate output struct.
28
+
29
+
If a procedure doesn't have a schema for a JSON body specified as it's input,
30
+
the top-level struct will instead have a `raw_input` field, allowing for
31
+
miscellaneous bodies such as a binary blob.
32
+
33
+
The generated structs also implement the `JSON.Encoder` and `Jason.Encoder`
34
+
protocols (the latter currently present for compatibility), as well as a
35
+
`from_json` function which is used to validate an input map - e.g. from a JSON
36
+
HTTP response - and turn it into a struct.
37
+
38
+
## Example
39
+
40
+
deflexicon(%{
41
+
"lexicon" => 1,
42
+
"id" => "com.ovyerus.testing",
43
+
"defs" => %{
44
+
"main" => %{
45
+
"type" => "record",
46
+
"key" => "tid",
47
+
"record" => %{
48
+
"type" => "object",
49
+
"required" => ["foobar"],
50
+
"properties" => %{ "foobar" => %{ "type" => "string" } }
51
+
}
52
+
}
53
+
}
54
+
})
55
+
56
+
The macro expands to following code (truncated for brevity):
57
+
58
+
@type main() :: %{required(:foobar) => String.t(), optional(:"$type") => String.t()}
59
+
@type t() :: %{required(:foobar) => String.t(), optional(:"$type") => String.t()}
60
+
61
+
defschema(:main, %{
62
+
foobar: {:required, {:custom, {Atex.Lexicon.Validators.String, :validate, [[]]}}},
63
+
"$type": {{:literal, "com.ovyerus.testing"}, {:default, "com.ovyerus.testing"}}
64
+
})
65
+
66
+
@enforce_keys [:foobar]
67
+
defstruct foobar: nil, "$type": "com.ovyerus.testing"
68
+
69
+
def from_json(json) do
70
+
case apply(Com.Ovyerus.Testing, :main, [json]) do
71
+
{:ok, map} -> {:ok, struct(__MODULE__, map)}
72
+
err -> err
73
+
end
74
+
end
75
+
76
+
The generated module can be used directly with `Atex.XRPC` functions, allowing
77
+
typeโsafe construction of requests and automatic decoding of responses.
78
+
"""
79
+
defmacro deflexicon(lexicon) do
80
+
# Better way to get the real map, without having to eval? (custom function to compose one from quoted?)
81
+
lexicon =
82
+
lexicon
83
+
|> Code.eval_quoted()
84
+
|> elem(0)
85
+
|> then(&Recase.Enumerable.atomize_keys/1)
86
+
|> then(&Atex.Lexicon.Schema.lexicon!/1)
87
+
88
+
defs =
89
+
lexicon.defs
90
+
|> Enum.flat_map(fn {def_name, def} -> def_to_schema(lexicon.id, def_name, def) end)
91
+
|> Enum.map(fn
92
+
{schema_key, quoted_schema, quoted_type} -> {schema_key, quoted_schema, quoted_type, nil}
93
+
x -> x
94
+
end)
95
+
|> Enum.map(fn {schema_key, quoted_schema, quoted_type, quoted_struct} ->
96
+
identity_type =
97
+
if schema_key == :main do
98
+
quote do
99
+
@type t() :: unquote(quoted_type)
100
+
end
101
+
end
102
+
103
+
struct_def =
104
+
if schema_key == :main do
105
+
quoted_struct
106
+
else
107
+
nested_module_name =
108
+
schema_key
109
+
|> Recase.to_pascal()
110
+
|> atomise()
111
+
112
+
quote do
113
+
defmodule unquote({:__aliases__, [alias: false], [nested_module_name]}) do
114
+
unquote(quoted_struct)
115
+
end
116
+
end
117
+
end
118
+
119
+
quote do
120
+
@type unquote(Recase.to_snake(schema_key))() :: unquote(quoted_type)
121
+
unquote(identity_type)
122
+
123
+
defschema unquote(Recase.to_snake(schema_key)), unquote(quoted_schema)
124
+
125
+
unquote(struct_def)
126
+
end
127
+
end)
128
+
129
+
quote do
130
+
def id, do: unquote(lexicon.id)
131
+
132
+
unquote_splicing(defs)
133
+
end
134
+
end
135
+
136
+
# - [ ] `t()` type should be the struct in it. (add to non-main structs too?)
137
+
138
+
@spec def_to_schema(nsid :: String.t(), def_name :: String.t(), lexicon_def :: map()) ::
139
+
list(
140
+
{
141
+
key :: atom(),
142
+
quoted_schema :: term(),
143
+
quoted_type :: term()
144
+
}
145
+
| {
146
+
key :: atom(),
147
+
quoted_schema :: term(),
148
+
quoted_type :: term(),
149
+
quoted_struct :: term()
150
+
}
151
+
)
152
+
153
+
defp def_to_schema(nsid, def_name, %{type: "record", record: record}) do
154
+
# TODO: record rkey format validator
155
+
type_name = Atex.NSID.canonical_name(nsid, to_string(def_name))
156
+
157
+
record =
158
+
put_in(record, [:properties, :"$type"], %{
159
+
type: "string",
160
+
const: type_name,
161
+
default: type_name
162
+
})
163
+
164
+
def_to_schema(nsid, def_name, record)
165
+
end
166
+
167
+
# TODO: add struct to types
168
+
defp def_to_schema(
169
+
nsid,
170
+
def_name,
171
+
%{
172
+
type: "object",
173
+
properties: properties
174
+
} = def
175
+
) do
176
+
required = Map.get(def, :required, [])
177
+
nullable = Map.get(def, :nullable, [])
178
+
179
+
{quoted_schemas, quoted_types} =
180
+
properties
181
+
|> Enum.map(fn {key, field} ->
182
+
{quoted_schema, quoted_type} = field_to_schema(field, nsid)
183
+
string_key = to_string(key)
184
+
is_nullable = string_key in nullable
185
+
is_required = string_key in required
186
+
187
+
quoted_schema =
188
+
quoted_schema
189
+
|> then(
190
+
&if is_nullable, do: quote(do: {:either, {{:literal, nil}, unquote(&1)}}), else: &1
191
+
)
192
+
|> then(&if is_required, do: quote(do: {:required, unquote(&1)}), else: &1)
193
+
|> then(&{key, &1})
194
+
195
+
key_type = if is_required, do: :required, else: :optional
196
+
197
+
quoted_type =
198
+
quoted_type
199
+
|> then(
200
+
&if is_nullable do
201
+
{:|, [], [&1, nil]}
202
+
else
203
+
&1
204
+
end
205
+
)
206
+
|> then(&{{key_type, [], [key]}, &1})
207
+
208
+
{quoted_schema, quoted_type}
209
+
end)
210
+
|> Enum.reduce({[], []}, fn {quoted_schema, quoted_type}, {schemas, types} ->
211
+
{[quoted_schema | schemas], [quoted_type | types]}
212
+
end)
213
+
214
+
struct_keys =
215
+
properties
216
+
|> Enum.filter(fn {key, _} -> key !== :"$type" end)
217
+
|> Enum.map(fn
218
+
{key, %{default: default}} -> {key, default}
219
+
{key, _field} -> {key, nil}
220
+
end)
221
+
|> then(&(&1 ++ [{:"$type", if(def_name == :main, do: nsid, else: "#{nsid}##{def_name}")}]))
222
+
223
+
enforced_keys =
224
+
properties |> Map.keys() |> Enum.filter(&(to_string(&1) in required && &1 != :"$type"))
225
+
226
+
optional_if_nil_keys =
227
+
properties
228
+
|> Map.keys()
229
+
|> Enum.filter(fn key ->
230
+
key = to_string(key)
231
+
# TODO: what if it is nullable but not required?
232
+
key not in required && key not in nullable && key != "$type"
233
+
end)
234
+
235
+
schema_module = Atex.NSID.to_atom(nsid)
236
+
237
+
quoted_struct =
238
+
quote do
239
+
@enforce_keys unquote(enforced_keys)
240
+
defstruct unquote(struct_keys)
241
+
242
+
def from_json(json) do
243
+
case apply(unquote(schema_module), unquote(atomise(def_name)), [json]) do
244
+
{:ok, map} -> {:ok, struct(__MODULE__, map)}
245
+
err -> err
246
+
end
247
+
end
248
+
249
+
defimpl JSON.Encoder do
250
+
@optional_if_nil_keys unquote(optional_if_nil_keys)
251
+
252
+
def encode(value, encoder) do
253
+
value
254
+
|> Map.from_struct()
255
+
|> Enum.reject(fn {k, v} -> k in @optional_if_nil_keys && v == nil end)
256
+
|> Enum.into(%{})
257
+
|> Jason.Encoder.encode(encoder)
258
+
end
259
+
end
260
+
261
+
defimpl Jason.Encoder do
262
+
@optional_if_nil_keys unquote(optional_if_nil_keys)
263
+
264
+
def encode(value, options) do
265
+
value
266
+
|> Map.from_struct()
267
+
|> Enum.reject(fn {k, v} -> k in @optional_if_nil_keys && v == nil end)
268
+
|> Enum.into(%{})
269
+
|> Jason.Encode.map(options)
270
+
end
271
+
end
272
+
end
273
+
274
+
[{atomise(def_name), {:%{}, [], quoted_schemas}, {:%{}, [], quoted_types}, quoted_struct}]
275
+
end
276
+
277
+
# TODO: validating errors?
278
+
defp def_to_schema(nsid, _def_name, %{type: "query"} = def) do
279
+
params =
280
+
if def[:parameters] do
281
+
[schema] =
282
+
def_to_schema(nsid, "params", %{
283
+
type: "object",
284
+
required: Map.get(def.parameters, :required, []),
285
+
properties: def.parameters.properties
286
+
})
287
+
288
+
schema
289
+
end
290
+
291
+
output =
292
+
if def[:output] && def.output[:schema] do
293
+
[schema] = def_to_schema(nsid, "output", def.output.schema)
294
+
schema
295
+
end
296
+
297
+
# Root struct containing `params`
298
+
main =
299
+
if params do
300
+
{
301
+
:main,
302
+
nil,
303
+
quote do
304
+
%__MODULE__{params: params()}
305
+
end,
306
+
quote do
307
+
@enforce_keys [:params]
308
+
defstruct params: nil
309
+
end
310
+
}
311
+
else
312
+
{
313
+
:main,
314
+
nil,
315
+
quote do
316
+
%__MODULE__{}
317
+
end,
318
+
quote do
319
+
defstruct []
320
+
end
321
+
}
322
+
end
323
+
324
+
[main, params, output]
325
+
|> Enum.reject(&is_nil/1)
326
+
end
327
+
328
+
defp def_to_schema(nsid, _def_name, %{type: "procedure"} = def) do
329
+
# TODO: better keys for these
330
+
params =
331
+
if def[:parameters] do
332
+
[schema] =
333
+
def_to_schema(nsid, "params", %{
334
+
type: "object",
335
+
required: Map.get(def.parameters, :required, []),
336
+
properties: def.parameters.properties
337
+
})
338
+
339
+
schema
340
+
end
341
+
342
+
output =
343
+
if def[:output] && def.output[:schema] do
344
+
[schema] = def_to_schema(nsid, "output", def.output.schema)
345
+
schema
346
+
end
347
+
348
+
input =
349
+
if def[:input] && def.input[:schema] do
350
+
[schema] = def_to_schema(nsid, "input", def.input.schema)
351
+
schema
352
+
end
353
+
354
+
# Root struct containing `input`, `raw_input`, and `params`
355
+
main =
356
+
{
357
+
:main,
358
+
nil,
359
+
cond do
360
+
params && input ->
361
+
quote do
362
+
%__MODULE__{input: input(), params: params()}
363
+
end
364
+
365
+
input ->
366
+
quote do
367
+
%__MODULE__{input: input()}
368
+
end
369
+
370
+
params ->
371
+
quote do
372
+
%__MODULE__{raw_input: any(), params: params()}
373
+
end
374
+
375
+
true ->
376
+
quote do
377
+
%__MODULE__{raw_input: any()}
378
+
end
379
+
end,
380
+
cond do
381
+
params && input ->
382
+
quote do
383
+
defstruct input: nil, params: nil
384
+
end
385
+
386
+
input ->
387
+
quote do
388
+
defstruct input: nil
389
+
end
390
+
391
+
params ->
392
+
quote do
393
+
defstruct raw_input: nil, params: nil
394
+
end
395
+
396
+
true ->
397
+
quote do
398
+
defstruct raw_input: nil
399
+
end
400
+
end
401
+
}
402
+
403
+
[main, params, output, input]
404
+
|> Enum.reject(&is_nil/1)
405
+
end
406
+
407
+
defp def_to_schema(nsid, _def_name, %{type: "subscription"} = def) do
408
+
params =
409
+
if def[:parameters] do
410
+
[schema] =
411
+
def_to_schema(nsid, "params", %{
412
+
type: "object",
413
+
required: Map.get(def.parameters, :required, []),
414
+
properties: def.parameters.properties
415
+
})
416
+
417
+
schema
418
+
end
419
+
420
+
message =
421
+
if def[:message] do
422
+
[schema] = def_to_schema(nsid, "message", def.message.schema)
423
+
schema
424
+
end
425
+
426
+
[params, message]
427
+
|> Enum.reject(&is_nil/1)
428
+
end
429
+
430
+
defp def_to_schema(_nsid, def_name, %{type: "token"}) do
431
+
# TODO: make it a validator that expects the nsid + key.
432
+
[
433
+
{
434
+
atomise(def_name),
435
+
:string,
436
+
quote do
437
+
String.t()
438
+
end
439
+
}
440
+
]
441
+
end
442
+
443
+
defp def_to_schema(nsid, def_name, %{type: type} = def)
444
+
when type in [
445
+
"blob",
446
+
"array",
447
+
"boolean",
448
+
"integer",
449
+
"string",
450
+
"bytes",
451
+
"cid-link",
452
+
"unknown",
453
+
"ref",
454
+
"union"
455
+
] do
456
+
{quoted_schema, quoted_type} = field_to_schema(def, nsid)
457
+
[{atomise(def_name), quoted_schema, quoted_type}]
458
+
end
459
+
460
+
@spec field_to_schema(field_def :: %{type: String.t()}, nsid :: String.t()) ::
461
+
{quoted_schema :: term(), quoted_typespec :: term()}
462
+
defp field_to_schema(%{type: "string"} = field, _nsid) do
463
+
fixed_schema = const_or_enum(field)
464
+
465
+
if fixed_schema do
466
+
maybe_default(fixed_schema, field)
467
+
else
468
+
field
469
+
|> Map.take([
470
+
:format,
471
+
:maxLength,
472
+
:minLength,
473
+
:maxGraphemes,
474
+
:minGraphemes
475
+
])
476
+
|> Enum.map(fn {k, v} -> {Recase.to_snake(k), v} end)
477
+
|> Validators.string()
478
+
|> maybe_default(field)
479
+
end
480
+
|> then(
481
+
&{Macro.escape(&1),
482
+
quote do
483
+
String.t()
484
+
end}
485
+
)
486
+
end
487
+
488
+
defp field_to_schema(%{type: "boolean"} = field, _nsid) do
489
+
(const(field) || :boolean)
490
+
|> maybe_default(field)
491
+
|> then(
492
+
&{Macro.escape(&1),
493
+
quote do
494
+
boolean()
495
+
end}
496
+
)
497
+
end
498
+
499
+
defp field_to_schema(%{type: "integer"} = field, _nsid) do
500
+
fixed_schema = const_or_enum(field)
501
+
502
+
if fixed_schema do
503
+
maybe_default(fixed_schema, field)
504
+
else
505
+
field
506
+
|> Map.take([:maximum, :minimum])
507
+
|> Keyword.new()
508
+
|> Validators.integer()
509
+
|> maybe_default(field)
510
+
end
511
+
|> then(
512
+
&{
513
+
Macro.escape(&1),
514
+
# TODO: turn into range definition based on maximum/minimum
515
+
quote do
516
+
integer()
517
+
end
518
+
}
519
+
)
520
+
end
521
+
522
+
defp field_to_schema(%{type: "array", items: items} = field, nsid) do
523
+
{inner_schema, inner_type} = field_to_schema(items, nsid)
524
+
525
+
field
526
+
|> Map.take([:maxLength, :minLength])
527
+
|> Enum.map(fn {k, v} -> {Recase.to_snake(k), v} end)
528
+
|> then(&Validators.array(inner_schema, &1))
529
+
|> then(&Macro.escape/1)
530
+
# TODO: we should be able to unquote this now...
531
+
# Can't unquote the inner_schema beforehand as that would risk evaluating `get_schema`s which don't exist yet.
532
+
# There's probably a better way to do this lol.
533
+
|> then(fn {:custom, {:{}, c, [Validators.Array, :validate, [quoted_inner_schema | args]]}} ->
534
+
{inner_schema, _} = Code.eval_quoted(quoted_inner_schema)
535
+
{:custom, {:{}, c, [Validators.Array, :validate, [inner_schema | args]]}}
536
+
end)
537
+
|> then(
538
+
&{&1,
539
+
quote do
540
+
list(unquote(inner_type))
541
+
end}
542
+
)
543
+
end
544
+
545
+
defp field_to_schema(%{type: "blob"} = field, _nsid) do
546
+
field
547
+
|> Map.take([:accept, :maxSize])
548
+
|> Enum.map(fn {k, v} -> {Recase.to_snake(k), v} end)
549
+
|> Validators.blob()
550
+
|> then(
551
+
&{Macro.escape(&1),
552
+
quote do
553
+
Validators.blob()
554
+
end}
555
+
)
556
+
end
557
+
558
+
defp field_to_schema(%{type: "bytes"} = field, _nsid) do
559
+
field
560
+
|> Map.take([:maxLength, :minLength])
561
+
|> Enum.map(fn {k, v} -> {Recase.to_snake(k), v} end)
562
+
|> Validators.bytes()
563
+
|> then(
564
+
&{Macro.escape(&1),
565
+
quote do
566
+
Validators.bytes()
567
+
end}
568
+
)
569
+
end
570
+
571
+
defp field_to_schema(%{type: "cid-link"}, _nsid) do
572
+
Validators.cid_link()
573
+
|> then(
574
+
&{Macro.escape(&1),
575
+
quote do
576
+
Validators.cid_link()
577
+
end}
578
+
)
579
+
end
580
+
581
+
# TODO: do i need to make sure these two deal with brands? Check objects in atp.tools
582
+
defp field_to_schema(%{type: "ref", ref: ref}, nsid) do
583
+
{nsid, fragment} =
584
+
nsid
585
+
|> Atex.NSID.expand_possible_fragment_shorthand(ref)
586
+
|> Atex.NSID.to_atom_with_fragment()
587
+
588
+
fragment = Recase.to_snake(fragment)
589
+
590
+
{
591
+
Macro.escape(Validators.lazy_ref(nsid, fragment)),
592
+
quote do
593
+
unquote(nsid).unquote(fragment)()
594
+
end
595
+
}
596
+
end
597
+
598
+
defp field_to_schema(%{type: "union", refs: refs}, nsid) do
599
+
if refs == [] do
600
+
{quote do
601
+
{:oneof, []}
602
+
end, nil}
603
+
else
604
+
refs
605
+
|> Enum.map(fn ref ->
606
+
{nsid, fragment} =
607
+
nsid
608
+
|> Atex.NSID.expand_possible_fragment_shorthand(ref)
609
+
|> Atex.NSID.to_atom_with_fragment()
610
+
611
+
fragment = Recase.to_snake(fragment)
612
+
613
+
{
614
+
Macro.escape(Validators.lazy_ref(nsid, fragment)),
615
+
quote do
616
+
unquote(nsid).unquote(fragment)()
617
+
end
618
+
}
619
+
end)
620
+
|> Enum.reduce({[], []}, fn {quoted_schema, quoted_type}, {schemas, types} ->
621
+
{[quoted_schema | schemas], [quoted_type | types]}
622
+
end)
623
+
|> then(fn {schemas, types} ->
624
+
{quote do
625
+
{:oneof, unquote(schemas)}
626
+
end,
627
+
quote do
628
+
unquote(join_with_pipe(types))
629
+
end}
630
+
end)
631
+
end
632
+
end
633
+
634
+
# TODO: apparently should be a data object, not a primitive?
635
+
defp field_to_schema(%{type: "unknown"}, _nsid) do
636
+
{:any,
637
+
quote do
638
+
term()
639
+
end}
640
+
end
641
+
642
+
defp field_to_schema(_field_def, _nsid), do: {nil, nil}
643
+
644
+
defp maybe_default(schema, field) do
645
+
if field[:default] != nil,
646
+
do: {schema, {:default, field.default}},
647
+
else: schema
648
+
end
649
+
650
+
defp const_or_enum(field), do: const(field) || enum(field)
651
+
652
+
defp const(%{const: value}), do: {:literal, value}
653
+
defp const(_), do: nil
654
+
655
+
defp enum(%{enum: values}), do: {:enum, values}
656
+
defp enum(_), do: nil
657
+
658
+
defp atomise(x) when is_atom(x), do: x
659
+
defp atomise(x) when is_binary(x), do: String.to_atom(x)
660
+
661
+
defp join_with_pipe(list) when is_list(list) do
662
+
[piped] = do_join_with_pipe(list)
663
+
piped
664
+
end
665
+
666
+
defp do_join_with_pipe([head]), do: [head]
667
+
defp do_join_with_pipe([head | tail]), do: [{:|, [], [head | do_join_with_pipe(tail)]}]
668
+
defp do_join_with_pipe([]), do: []
669
+
end
+57
lib/atex/nsid.ex
+57
lib/atex/nsid.ex
···
1
+
defmodule Atex.NSID do
2
+
@re ~r/^[a-zA-Z](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?:\.[a-zA-Z](?:[a-zA-Z0-9]{0,62})?)$/
3
+
# TODO: regex with support for fragment
4
+
5
+
@spec re() :: Regex.t()
6
+
def re, do: @re
7
+
8
+
@spec match?(String.t()) :: boolean()
9
+
def match?(value), do: Regex.match?(@re, value)
10
+
11
+
# TODO: methods for fetching the authority and name from a nsid.
12
+
# maybe stuff for fetching the repo that belongs to an authority
13
+
14
+
@spec to_atom(String.t()) :: atom()
15
+
def to_atom(nsid, fully_qualify \\ true) do
16
+
nsid
17
+
|> String.split(".")
18
+
|> Enum.map(&Recase.to_pascal/1)
19
+
|> then(fn parts ->
20
+
if fully_qualify do
21
+
["Elixir" | parts]
22
+
else
23
+
parts
24
+
end
25
+
end)
26
+
|> Enum.join(".")
27
+
|> String.to_atom()
28
+
end
29
+
30
+
@spec to_atom_with_fragment(String.t()) :: {atom(), atom()}
31
+
def to_atom_with_fragment(nsid) do
32
+
if !String.contains?(nsid, "#") do
33
+
{to_atom(nsid), :main}
34
+
else
35
+
[nsid, fragment] = String.split(nsid, "#")
36
+
{to_atom(nsid), String.to_atom(fragment)}
37
+
end
38
+
end
39
+
40
+
@spec expand_possible_fragment_shorthand(String.t(), String.t()) :: String.t()
41
+
def expand_possible_fragment_shorthand(main_nsid, possible_fragment) do
42
+
if String.starts_with?(possible_fragment, "#") do
43
+
main_nsid <> possible_fragment
44
+
else
45
+
possible_fragment
46
+
end
47
+
end
48
+
49
+
@spec canonical_name(String.t(), String.t()) :: String.t()
50
+
def canonical_name(nsid, fragment) do
51
+
if fragment == "main" do
52
+
nsid
53
+
else
54
+
"#{nsid}##{fragment}"
55
+
end
56
+
end
57
+
end
+127
lib/atex/oauth/cache.ex
+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
+25
lib/atex/oauth/error.ex
+25
lib/atex/oauth/error.ex
···
1
+
defmodule Atex.OAuth.Error do
2
+
@moduledoc """
3
+
Exception raised by `Atex.OAuth.Plug` when errors occurred. When using the
4
+
Plug, you should set up a `Plug.ErrorHandler` to gracefully catch these and
5
+
give messages to the end user.
6
+
7
+
This extesion has two fields: a human-readable `message` string, and an atom
8
+
`reason` for each specific error.
9
+
10
+
## Reasons
11
+
12
+
- `:missing_handle` - The handle query parameter was not provided
13
+
- `:invalid_handle` - The provided handle could not be resolved
14
+
- `:authorization_url_failed` - Failed to create the authorization URL
15
+
- `:invalid_callback_request` - Missing or invalid state/code in callback
16
+
- `:authorization_server_metadata_failed` - Could not fetch authorization
17
+
server metadata
18
+
- `:token_validation_failed` - Failed to validate the authorization code or
19
+
token
20
+
- `:issuer_mismatch` - OAuth issuer does not match PDS authorization server
21
+
- `:session_store_failed` - OAuth succeeded but failed to store the session
22
+
"""
23
+
24
+
defexception [:message, :reason]
25
+
end
+809
lib/atex/oauth/permission.ex
+809
lib/atex/oauth/permission.ex
···
1
+
defmodule Atex.OAuth.Permission do
2
+
use TypedStruct
3
+
import Kernel, except: [to_string: 1]
4
+
5
+
@type t_tuple() :: {
6
+
resource :: String.t(),
7
+
positional :: String.t() | nil,
8
+
parameters :: list({String.t(), String.t()})
9
+
}
10
+
11
+
@typep as_string() :: {:as_string, boolean()}
12
+
@type account_attr() :: :email | :repo
13
+
@type account_action() :: :read | :manage
14
+
@type account_opt() ::
15
+
{:attr, account_attr()} | {:action, account_action()} | as_string()
16
+
17
+
@type repo_opt() ::
18
+
{:create, boolean()} | {:update, boolean()} | {:delete, boolean()} | as_string()
19
+
20
+
@type rpc_opt() :: {:aud, String.t()} | {:inherit_aud, boolean()} | as_string()
21
+
22
+
@type include_opt() :: {:aud, String.t()} | as_string()
23
+
24
+
typedstruct enforce: true do
25
+
field :resource, String.t()
26
+
field :positional, String.t() | nil
27
+
# like a Keyword list but with a string instead of an atom
28
+
field :parameters, list({String.t(), String.t()}), enforce: false, default: []
29
+
end
30
+
31
+
@doc """
32
+
Creates a new permission struct from a permission scope string.
33
+
34
+
Parses an AT Protocol OAuth permission scope string and returns a structured
35
+
representation. Permission strings follow the format
36
+
`resource:positional?key=value&key2=value2`
37
+
38
+
The positional parameter is resource-specific and may be omitted in some cases
39
+
(e.g., collection for `repo`, lxm for `rpc`, attr for `account`/`identity`,
40
+
accept for `blob`).
41
+
42
+
See the [AT Protocol
43
+
documentation](https://atproto.com/specs/permission#scope-string-syntax) for
44
+
the full syntax and rules for permission scope strings.
45
+
46
+
## Parameters
47
+
- `string` - A permission scope string (e.g., "repo:app.example.profile")
48
+
49
+
Returns `{:ok, permission}` if a valid scope string was given, otherwise it
50
+
will return `{:error, reason}`.
51
+
52
+
## Examples
53
+
54
+
# Simple with just a positional
55
+
iex> Atex.OAuth.Permission.new("repo:app.example.profile")
56
+
{:ok, %Atex.OAuth.Permission{
57
+
resource: "repo",
58
+
positional: "app.example.profile",
59
+
parameters: []
60
+
}}
61
+
62
+
# With parameters
63
+
iex> Atex.OAuth.Permission.new("repo?collection=app.example.profile&collection=app.example.post")
64
+
{:ok, %Atex.OAuth.Permission{
65
+
resource: "repo",
66
+
positional: nil,
67
+
parameters: [
68
+
{"collection", "app.example.profile"},
69
+
{"collection", "app.example.post"}
70
+
]
71
+
}}
72
+
73
+
# Positional with parameters
74
+
iex> Atex.OAuth.Permission.new("rpc:app.example.moderation.createReport?aud=*")
75
+
{:ok, %Atex.OAuth.Permission{
76
+
resource: "rpc",
77
+
positional: "app.example.moderation.createReport",
78
+
parameters: [{"aud", "*"}]
79
+
}}
80
+
81
+
iex> Atex.OAuth.Permission.new("blob:*/*")
82
+
{:ok, %Atex.OAuth.Permission{
83
+
resource: "blob",
84
+
positional: "*/*",
85
+
parameters: []
86
+
}}
87
+
88
+
# Invalid: resource without positional or parameters
89
+
iex> Atex.OAuth.Permission.new("resource")
90
+
{:error, :missing_positional_or_parameters}
91
+
92
+
"""
93
+
@spec new(String.t()) :: {:ok, t()} | {:error, reason :: atom()}
94
+
def new(string) do
95
+
case parse(string) do
96
+
{:ok, {resource, positional, parameters}} ->
97
+
{:ok, %__MODULE__{resource: resource, positional: positional, parameters: parameters}}
98
+
99
+
err ->
100
+
err
101
+
end
102
+
end
103
+
104
+
@doc """
105
+
Parses an AT Protocol permission scope string into its components.
106
+
107
+
Returns a tuple containing the resource name, optional positional parameter,
108
+
and a list of key-value parameter pairs. This is a lower-level function
109
+
compared to `new/1`, returning the raw components instead of a struct.
110
+
111
+
## Parameters
112
+
- `string` - A permission scope string following the format
113
+
`resource:positional?key=value&key2=value2`
114
+
115
+
Returns `{:ok, {resource, positional, parameters}}` if a valid scope string
116
+
was given, otherwise it will return `{:error, reason}`.
117
+
118
+
## Examples
119
+
120
+
# Simple with just a positional
121
+
iex> Atex.OAuth.Permission.parse("repo:app.example.profile")
122
+
{:ok, {"repo", "app.example.profile", []}}
123
+
124
+
# With parameters
125
+
iex> Atex.OAuth.Permission.parse("repo?collection=app.example.profile&collection=app.example.post")
126
+
{:ok, {
127
+
"repo",
128
+
nil,
129
+
[
130
+
{"collection", "app.example.profile"},
131
+
{"collection", "app.example.post"}
132
+
]
133
+
}}
134
+
135
+
# Positional with parameters
136
+
iex> Atex.OAuth.Permission.parse("rpc:app.example.moderation.createReport?aud=*")
137
+
{:ok, {"rpc", "app.example.moderation.createReport", [{"aud", "*"}]}}
138
+
139
+
iex> Atex.OAuth.Permission.parse("blob:*/*")
140
+
{:ok, {"blob", "*/*", []}}
141
+
142
+
# Invalid: resource without positional or parameters
143
+
iex> Atex.OAuth.Permission.parse("resource")
144
+
{:error, :missing_positional_or_parameters}
145
+
146
+
"""
147
+
@spec parse(String.t()) ::
148
+
{:ok, t_tuple()}
149
+
| {:error, reason :: atom()}
150
+
def parse(string) do
151
+
case String.split(string, "?", parts: 2) do
152
+
[resource_part] ->
153
+
parse_resource_and_positional(resource_part)
154
+
155
+
# Empty parameter string is treated as absent
156
+
[resource_part, ""] ->
157
+
parse_resource_and_positional(resource_part)
158
+
159
+
[resource_part, params_part] ->
160
+
params_part
161
+
|> parse_parameters()
162
+
|> then(&parse_resource_and_positional(resource_part, &1))
163
+
end
164
+
end
165
+
166
+
@spec parse_resource_and_positional(String.t(), list({String.t(), String.t()})) ::
167
+
{:ok, t_tuple()} | {:error, reason :: atom()}
168
+
defp parse_resource_and_positional(resource_part, parameters \\ []) do
169
+
case String.split(resource_part, ":", parts: 2) do
170
+
[resource_name, positional] ->
171
+
{:ok, {resource_name, positional, parameters}}
172
+
173
+
[resource_name] ->
174
+
if parameters == [] do
175
+
{:error, :missing_positional_or_parameters}
176
+
else
177
+
{:ok, {resource_name, nil, parameters}}
178
+
end
179
+
end
180
+
end
181
+
182
+
@spec parse_parameters(String.t()) :: list({String.t(), String.t()})
183
+
defp parse_parameters(params_string) do
184
+
params_string
185
+
|> String.split("&")
186
+
|> Enum.map(fn param ->
187
+
case String.split(param, "=", parts: 2) do
188
+
[key, value] -> {key, URI.decode(value)}
189
+
[key] -> {key, ""}
190
+
end
191
+
end)
192
+
end
193
+
194
+
@doc """
195
+
Converts a permission struct back into its scope string representation.
196
+
197
+
This is the inverse operation of `new/1`, converting a structured permission
198
+
back into the AT Protocol OAuth scope string format. The resulting string
199
+
can be used directly as an OAuth scope parameter.
200
+
201
+
Values in `parameters` are automatically URL-encoded as needed (e.g., `#` becomes `%23`).
202
+
203
+
## Parameters
204
+
- `struct` - An `%Atex.OAuth.Permission{}` struct
205
+
206
+
Returns a permission scope string.
207
+
208
+
## Examples
209
+
210
+
# Simple with just a positional
211
+
iex> perm = %Atex.OAuth.Permission{
212
+
...> resource: "repo",
213
+
...> positional: "app.example.profile",
214
+
...> parameters: []
215
+
...> }
216
+
iex> Atex.OAuth.Permission.to_string(perm)
217
+
"repo:app.example.profile"
218
+
219
+
# With parameters
220
+
iex> perm = %Atex.OAuth.Permission{
221
+
...> resource: "repo",
222
+
...> positional: nil,
223
+
...> parameters: [
224
+
...> {"collection", "app.example.profile"},
225
+
...> {"collection", "app.example.post"}
226
+
...> ]
227
+
...> }
228
+
iex> Atex.OAuth.Permission.to_string(perm)
229
+
"repo?collection=app.example.profile&collection=app.example.post"
230
+
231
+
# Positional with parameters
232
+
iex> perm = %Atex.OAuth.Permission{
233
+
...> resource: "rpc",
234
+
...> positional: "app.example.moderation.createReport",
235
+
...> parameters: [{"aud", "*"}]
236
+
...> }
237
+
iex> Atex.OAuth.Permission.to_string(perm)
238
+
"rpc:app.example.moderation.createReport?aud=*"
239
+
240
+
iex> perm = %Atex.OAuth.Permission{
241
+
...> resource: "blob",
242
+
...> positional: "*/*",
243
+
...> parameters: []
244
+
...> }
245
+
iex> Atex.OAuth.Permission.to_string(perm)
246
+
"blob:*/*"
247
+
248
+
# Works via String.Chars protocol
249
+
iex> perm = %Atex.OAuth.Permission{
250
+
...> resource: "account",
251
+
...> positional: "email",
252
+
...> parameters: []
253
+
...> }
254
+
iex> to_string(perm)
255
+
"account:email"
256
+
257
+
"""
258
+
@spec to_string(t()) :: String.t()
259
+
def to_string(%__MODULE__{} = struct) do
260
+
positional_part = if struct.positional, do: ":#{struct.positional}", else: ""
261
+
parameters_part = stringify_parameters(struct.parameters)
262
+
263
+
struct.resource <> positional_part <> parameters_part
264
+
end
265
+
266
+
@spec stringify_parameters(list({String.t(), String.t()})) :: String.t()
267
+
defp stringify_parameters([]), do: ""
268
+
269
+
defp stringify_parameters(params) do
270
+
params
271
+
|> Enum.map(fn {key, value} -> "#{key}=#{encode_param_value(value)}" end)
272
+
|> Enum.join("&")
273
+
|> then(&"?#{&1}")
274
+
end
275
+
276
+
# Encode parameter values for OAuth scope strings
277
+
# Preserves unreserved characters (A-Z, a-z, 0-9, -, ., _, ~) and common scope characters (*, :, /)
278
+
# Encodes reserved characters like # as %23
279
+
@spec encode_param_value(String.t()) :: String.t()
280
+
defp encode_param_value(value) do
281
+
URI.encode(value, fn char ->
282
+
URI.char_unreserved?(char) or char in [?*, ?:, ?/]
283
+
end)
284
+
end
285
+
286
+
@doc """
287
+
Creates an account permission for controlling PDS account hosting details.
288
+
289
+
Controls access to private account information such as email address and
290
+
repository import capabilities. These permissions cannot be included in
291
+
permission sets and must be requested directly by client apps.
292
+
293
+
See the [AT Protocol documentation](https://atproto.com/specs/permission#account)
294
+
for more information.
295
+
296
+
## Options
297
+
- `:attr` (required) - A component of account configuration. Must be `:email`
298
+
or `:repo`.
299
+
- `:action` (optional) - Degree of control. Can be `:read` or `:manage`.
300
+
Defaults to `:read`.
301
+
- `:as_string` (optional) - If `true` (default), returns a scope string,
302
+
otherwise returns a Permission struct.
303
+
304
+
If `:as_string` is true a scope string is returned, otherwise the underlying
305
+
Permission struct is returned.
306
+
307
+
## Examples
308
+
309
+
# Read account email (default action, as string)
310
+
iex> Atex.OAuth.Permission.account(attr: :email)
311
+
"account:email"
312
+
313
+
# Read account email (as struct)
314
+
iex> Atex.OAuth.Permission.account(attr: :email, as_string: false)
315
+
%Atex.OAuth.Permission{
316
+
resource: "account",
317
+
positional: "email",
318
+
parameters: []
319
+
}
320
+
321
+
# Read account email (explicit action)
322
+
iex> Atex.OAuth.Permission.account(attr: :email, action: :read)
323
+
"account:email?action=read"
324
+
325
+
# Manage account email
326
+
iex> Atex.OAuth.Permission.account(attr: :email, action: :manage)
327
+
"account:email?action=manage"
328
+
329
+
# Import repo
330
+
iex> Atex.OAuth.Permission.account(attr: :repo, action: :manage)
331
+
"account:repo?action=manage"
332
+
333
+
"""
334
+
@spec account(list(account_opt())) :: t() | String.t()
335
+
def account(opts \\ []) do
336
+
opts = Keyword.validate!(opts, attr: nil, action: nil, as_string: true)
337
+
attr = Keyword.get(opts, :attr)
338
+
action = Keyword.get(opts, :action)
339
+
as_string = Keyword.get(opts, :as_string)
340
+
341
+
cond do
342
+
is_nil(attr) ->
343
+
raise ArgumentError, "option `:attr` must be provided."
344
+
345
+
attr not in [:email, :repo] ->
346
+
raise ArgumentError, "option `:attr` must be `:email` or `:repo`."
347
+
348
+
action not in [nil, :read, :manage] ->
349
+
raise ArgumentError, "option `:action` must be `:read`, `:manage`, or `nil`."
350
+
351
+
true ->
352
+
struct = %__MODULE__{
353
+
resource: "account",
354
+
positional: Atom.to_string(attr),
355
+
parameters: if(!is_nil(action), do: [{"action", Atom.to_string(action)}], else: [])
356
+
}
357
+
358
+
if as_string, do: to_string(struct), else: struct
359
+
end
360
+
end
361
+
362
+
@doc """
363
+
Creates a blob permission for uploading media files to PDS.
364
+
365
+
Controls the ability to upload blobs (media files) to the PDS. Permissions can
366
+
be restricted by MIME type patterns.
367
+
368
+
See the [AT Protocol documentation](https://atproto.com/specs/permission#blob)
369
+
for more information.
370
+
371
+
<!-- TODO: When permission sets are supported, add the note from the docs about this not being allowed in permisison sets. -->
372
+
373
+
## Parameters
374
+
- `accept` - A single MIME type string or list of MIME type strings/patterns.
375
+
Supports glob patterns like `"*/*"` or `"video/*"`.
376
+
- `opts` - Keyword list of options.
377
+
378
+
## Options
379
+
- `:as_string` (optional) - If `true` (default), returns a scope string, otherwise
380
+
returns a Permission struct.
381
+
382
+
If `:as_string` is true a scope string is returned, otherwise the underlying
383
+
Permission struct is returned.
384
+
385
+
## Examples
386
+
387
+
# Upload any type of blob
388
+
iex> Atex.OAuth.Permission.blob("*/*")
389
+
"blob:*/*"
390
+
391
+
# Only images
392
+
iex> Atex.OAuth.Permission.blob("image/*", as_string: false)
393
+
%Atex.OAuth.Permission{
394
+
resource: "blob",
395
+
positional: "image/*",
396
+
parameters: []
397
+
}
398
+
399
+
# Multiple mimetypes
400
+
iex> Atex.OAuth.Permission.blob(["video/*", "text/html"])
401
+
"blob?accept=video/*&accept=text/html"
402
+
403
+
# Multiple more specific mimetypes
404
+
iex> Atex.OAuth.Permission.blob(["image/png", "image/jpeg"], as_string: false)
405
+
%Atex.OAuth.Permission{
406
+
resource: "blob",
407
+
positional: nil,
408
+
parameters: [{"accept", "image/png"}, {"accept", "image/jpeg"}]
409
+
}
410
+
411
+
"""
412
+
# TODO: should probably validate that these at least look like mimetypes (~r"^.+/.+$")
413
+
@spec blob(String.t() | list(String.t()), list(as_string())) :: t() | String.t()
414
+
def blob(accept, opts \\ [])
415
+
416
+
def blob(accept, opts) when is_binary(accept) do
417
+
opts = Keyword.validate!(opts, as_string: true)
418
+
as_string = Keyword.get(opts, :as_string)
419
+
struct = %__MODULE__{resource: "blob", positional: accept}
420
+
if as_string, do: to_string(struct), else: struct
421
+
end
422
+
423
+
def blob(accept, opts) when is_list(accept) do
424
+
opts = Keyword.validate!(opts, as_string: true)
425
+
as_string = Keyword.get(opts, :as_string)
426
+
427
+
struct = %__MODULE__{
428
+
resource: "blob",
429
+
positional: nil,
430
+
parameters: Enum.map(accept, &{"accept", &1})
431
+
}
432
+
433
+
if as_string, do: to_string(struct), else: struct
434
+
end
435
+
436
+
@doc """
437
+
Creates an identity permission for controlling network identity.
438
+
439
+
Controls access to the account's DID document and handle. Note that the PDS
440
+
might not be able to facilitate identity changes if it does not have control
441
+
over the DID document (e.g., when using `did:web`).
442
+
443
+
<!-- TODO: same thing about not allowed in permission sets. -->
444
+
445
+
See the [AT Protocol
446
+
documentation](https://atproto.com/specs/permission#identity) for more
447
+
information.
448
+
449
+
## Parameters
450
+
- `attr` - An aspect or component of identity. Must be `:handle` or `:*`
451
+
(wildcard).
452
+
- `opts` - Keyword list of options.
453
+
454
+
## Options
455
+
- `:as_string` (optional) - If `true` (default), returns a scope string,
456
+
otherwise returns a Permission struct.
457
+
458
+
If `:as_string` is true a scope string is returned, otherwise the underlying
459
+
Permission struct is returned.
460
+
461
+
## Examples
462
+
463
+
# Update account handle (as string)
464
+
iex> Atex.OAuth.Permission.identity(:handle)
465
+
"identity:handle"
466
+
467
+
# Full identity control (as struct)
468
+
iex> Atex.OAuth.Permission.identity(:*, as_string: false)
469
+
%Atex.OAuth.Permission{
470
+
resource: "identity",
471
+
positional: "*",
472
+
parameters: []
473
+
}
474
+
475
+
"""
476
+
@spec identity(:handle | :*, list(as_string())) :: t() | String.t()
477
+
def identity(attr, opts \\ []) when attr in [:handle, :*] do
478
+
opts = Keyword.validate!(opts, as_string: true)
479
+
as_string = Keyword.get(opts, :as_string)
480
+
481
+
struct = %__MODULE__{
482
+
resource: "identity",
483
+
positional: Atom.to_string(attr)
484
+
}
485
+
486
+
if as_string, do: to_string(struct), else: struct
487
+
end
488
+
489
+
@doc """
490
+
Creates a repo permission for write access to records in the account's public
491
+
repository.
492
+
493
+
Controls write access to specific record types (collections) with optional
494
+
restrictions on the types of operations allowed (create, update, delete).
495
+
496
+
When no options are provided, all operations are permitted. When any action
497
+
option is explicitly set, only the actions set to `true` are enabled. This
498
+
allows for precise control over permissions.
499
+
500
+
See the [AT Protocol documentation](https://atproto.com/specs/permission#repo)
501
+
for more information.
502
+
503
+
## Parameters
504
+
- `collection_or_collections` - A single collection NSID string or list of
505
+
collection NSIDs. Use `"*"` for wildcard access to all record types (not
506
+
allowed in permission sets).
507
+
- `options` - Keyword list to restrict operations. If omitted, all operations
508
+
are allowed. If any action is specified, only explicitly enabled actions are
509
+
permitted.
510
+
511
+
## Options
512
+
- `:create` - Allow creating new records.
513
+
- `:update` - Allow updating existing records.
514
+
- `:delete` - Allow deleting records.
515
+
- `:as_string` (optional) - If `true` (default), returns a scope string,
516
+
otherwise returns a Permission struct.
517
+
518
+
If `:as_string` is true a scope string is returned, otherwise the underlying
519
+
Permission struct is returned.
520
+
521
+
## Examples
522
+
523
+
# Full permission on a single record type (all actions enabled, actions omitted)
524
+
iex> Atex.OAuth.Permission.repo("app.example.profile")
525
+
"repo:app.example.profile"
526
+
527
+
# Create only permission (other actions implicitly disabled)
528
+
iex> Atex.OAuth.Permission.repo("app.example.post", create: true, as_string: false)
529
+
%Atex.OAuth.Permission{
530
+
resource: "repo",
531
+
positional: "app.example.post",
532
+
parameters: [{"action", "create"}]
533
+
}
534
+
535
+
# Delete only permission
536
+
iex> Atex.OAuth.Permission.repo("app.example.like", delete: true)
537
+
"repo:app.example.like?action=delete"
538
+
539
+
# Create and update only, delete implicitly disabled
540
+
iex> Atex.OAuth.Permission.repo("app.example.repost", create: true, update: true)
541
+
"repo:app.example.repost?action=update&action=create"
542
+
543
+
# Multiple collections with full permissions (no options provided, actions omitted)
544
+
iex> Atex.OAuth.Permission.repo(["app.example.profile", "app.example.post"])
545
+
"repo?collection=app.example.profile&collection=app.example.post"
546
+
547
+
# Multiple collections with only update permission (as struct)
548
+
iex> Atex.OAuth.Permission.repo(["app.example.like", "app.example.repost"], update: true, as_string: false)
549
+
%Atex.OAuth.Permission{
550
+
resource: "repo",
551
+
positional: nil,
552
+
parameters: [
553
+
{"collection", "app.example.like"},
554
+
{"collection", "app.example.repost"},
555
+
{"action", "update"}
556
+
]
557
+
}
558
+
559
+
# Wildcard permission (all record types, all actions enabled, actions omitted)
560
+
iex> Atex.OAuth.Permission.repo("*")
561
+
"repo:*"
562
+
"""
563
+
@spec repo(String.t() | list(String.t()), list(repo_opt())) :: t() | String.t()
564
+
def repo(collection_or_collections, actions \\ [create: true, update: true, delete: true])
565
+
566
+
def repo(_collection, []),
567
+
do:
568
+
raise(
569
+
ArgumentError,
570
+
":actions must not be an empty list. If you want to have all actions enabled, either set them explicitly or remove the empty list argument."
571
+
)
572
+
573
+
def repo(collection, actions) when is_binary(collection), do: repo([collection], actions)
574
+
575
+
def repo(collections, actions) when is_list(collections) do
576
+
actions =
577
+
Keyword.validate!(actions, [:create, :update, :delete, as_string: true])
578
+
579
+
# Check if any action keys were explicitly provided
580
+
has_explicit_actions =
581
+
Keyword.has_key?(actions, :create) ||
582
+
Keyword.has_key?(actions, :update) ||
583
+
Keyword.has_key?(actions, :delete)
584
+
585
+
# If no action keys provided, default all to true; otherwise use explicit values
586
+
create = if has_explicit_actions, do: Keyword.get(actions, :create, false), else: true
587
+
update = if has_explicit_actions, do: Keyword.get(actions, :update, false), else: true
588
+
delete = if has_explicit_actions, do: Keyword.get(actions, :delete, false), else: true
589
+
all_actions_true = create && update && delete
590
+
591
+
as_string = Keyword.get(actions, :as_string)
592
+
singular_collection = length(collections) == 1
593
+
collection_parameters = Enum.map(collections, &{"collection", &1})
594
+
595
+
parameters =
596
+
[]
597
+
|> add_repo_param(:create, create, all_actions_true)
598
+
|> add_repo_param(:update, update, all_actions_true)
599
+
|> add_repo_param(:delete, delete, all_actions_true)
600
+
|> add_repo_param(:collections, collection_parameters)
601
+
602
+
struct = %__MODULE__{
603
+
resource: "repo",
604
+
positional: if(singular_collection, do: hd(collections)),
605
+
parameters: parameters
606
+
}
607
+
608
+
if as_string, do: to_string(struct), else: struct
609
+
end
610
+
611
+
# When all actions are true, omit them
612
+
defp add_repo_param(list, _type, _value, true), do: list
613
+
# Otherwise add them in
614
+
defp add_repo_param(list, :create, true, false), do: [{"action", "create"} | list]
615
+
defp add_repo_param(list, :update, true, false), do: [{"action", "update"} | list]
616
+
defp add_repo_param(list, :delete, true, false), do: [{"action", "delete"} | list]
617
+
618
+
# Catch-all for 4-arity version (must be before 3-arity)
619
+
defp add_repo_param(list, _type, _value, _all_true), do: list
620
+
621
+
defp add_repo_param(list, :collections, [_ | [_ | _]] = collections),
622
+
do: Enum.concat(collections, list)
623
+
624
+
defp add_repo_param(list, _type, _value), do: list
625
+
626
+
@doc """
627
+
Creates an RPC permission for authenticated API requests to remote services.
628
+
629
+
The permission is parameterised by the remote endpoint (`lxm`, short for
630
+
"Lexicon Method") and the identity of the remote service (the audience,
631
+
`aud`). Permissions must be restricted by at least one of these parameters.
632
+
633
+
See the [AT Protocol documentation](https://atproto.com/specs/permission#rpc)
634
+
for more information.
635
+
636
+
## Parameters
637
+
- `lxm` - A single NSID string or list of NSID strings representing API
638
+
endpoints. Use `"*"` for wildcard access to all endpoints.
639
+
- `opts` - Keyword list of options.
640
+
641
+
## Options
642
+
- `:aud` (semi-required) - Audience of API requests as a DID service
643
+
reference (e.g., `"did:web:api.example.com#srvtype"`). Supports wildcard
644
+
(`"*"`).
645
+
- `:inherit_aud` (optional) - If `true`, the `aud` value will be inherited
646
+
from permission set invocation context. Only used inside permission sets.
647
+
- `:as_string` (optional) - If `true` (default), returns a scope string,
648
+
otherwise returns a Permission struct.
649
+
650
+
> #### Note {: .info}
651
+
>
652
+
> `aud` and `lxm` cannot both be wildcard. The permission must be restricted
653
+
> by at least one of them.
654
+
655
+
If `:as_string` is true a scope string is returned, otherwise the underlying
656
+
Permission struct is returned.
657
+
658
+
## Examples
659
+
660
+
# Single endpoint with wildcard audience (as string)
661
+
iex> Atex.OAuth.Permission.rpc("app.example.moderation.createReport", aud: "*")
662
+
"rpc:app.example.moderation.createReport?aud=*"
663
+
664
+
# Multiple endpoints with specific service (as struct)
665
+
iex> Atex.OAuth.Permission.rpc(
666
+
...> ["app.example.getFeed", "app.example.getProfile"],
667
+
...> aud: "did:web:api.example.com#svc_appview",
668
+
...> as_string: false
669
+
...> )
670
+
%Atex.OAuth.Permission{
671
+
resource: "rpc",
672
+
positional: nil,
673
+
parameters: [
674
+
{"aud", "did:web:api.example.com#svc_appview"},
675
+
{"lxm", "app.example.getFeed"},
676
+
{"lxm", "app.example.getProfile"}
677
+
]
678
+
}
679
+
680
+
# Wildcard method with specific service
681
+
iex> Atex.OAuth.Permission.rpc("*", aud: "did:web:api.example.com#svc_appview")
682
+
"rpc:*?aud=did:web:api.example.com%23svc_appview"
683
+
684
+
# Single endpoint with inherited audience (for permission sets)
685
+
iex> Atex.OAuth.Permission.rpc("app.example.getPreferences", inherit_aud: true)
686
+
"rpc:app.example.getPreferences?inheritAud=true"
687
+
688
+
"""
689
+
@spec rpc(String.t() | list(String.t()), list(rpc_opt())) :: t() | String.t()
690
+
def rpc(lxm_or_lxms, opts \\ [])
691
+
def rpc(lxm, opts) when is_binary(lxm), do: rpc([lxm], opts)
692
+
693
+
def rpc(lxms, opts) when is_list(lxms) do
694
+
opts = Keyword.validate!(opts, aud: nil, inherit_aud: false, as_string: true)
695
+
aud = Keyword.get(opts, :aud)
696
+
inherit_aud = Keyword.get(opts, :inherit_aud)
697
+
as_string = Keyword.get(opts, :as_string)
698
+
699
+
# Validation: must have at least one of aud or inherit_aud
700
+
cond do
701
+
is_nil(aud) && !inherit_aud ->
702
+
raise ArgumentError,
703
+
"RPC permissions must specify either `:aud` or `:inheritAud` option."
704
+
705
+
!is_nil(aud) && inherit_aud ->
706
+
raise ArgumentError,
707
+
"RPC permissions cannot specify both `:aud` and `:inheritAud` options."
708
+
709
+
# Both lxm and aud cannot be wildcard
710
+
length(lxms) == 1 && hd(lxms) == "*" && aud == "*" ->
711
+
raise ArgumentError, "RPC permissions cannot have both wildcard `lxm` and wildcard `aud`."
712
+
713
+
true ->
714
+
singular_lxm = length(lxms) == 1
715
+
lxm_parameters = Enum.map(lxms, &{"lxm", &1})
716
+
717
+
parameters =
718
+
cond do
719
+
inherit_aud && singular_lxm ->
720
+
[{"inheritAud", "true"}]
721
+
722
+
inherit_aud ->
723
+
[{"inheritAud", "true"} | lxm_parameters]
724
+
725
+
singular_lxm ->
726
+
[{"aud", aud}]
727
+
728
+
true ->
729
+
[{"aud", aud} | lxm_parameters]
730
+
end
731
+
732
+
struct = %__MODULE__{
733
+
resource: "rpc",
734
+
positional: if(singular_lxm, do: hd(lxms)),
735
+
parameters: parameters
736
+
}
737
+
738
+
if as_string, do: to_string(struct), else: struct
739
+
end
740
+
end
741
+
742
+
@doc """
743
+
Creates an include permission for referencing a permission set.
744
+
745
+
Permission sets are Lexicon schemas that bundle together multiple permissions
746
+
under a single NSID. This allows developers to request a group of related
747
+
permissions with a single scope string, improving user experience by reducing
748
+
the number of individual permissions that need to be reviewed.
749
+
750
+
The `nsid` parameter is required and must be a valid NSID that resolves to a
751
+
permission set Lexicon schema. An optional `aud` parameter can be used to specify
752
+
the audience for any RPC permissions within the set that have `inheritAud: true`.
753
+
754
+
See the [AT Protocol documentation](https://atproto.com/specs/permission#permission-sets)
755
+
for more information.
756
+
757
+
## Parameters
758
+
- `nsid` - The NSID of the permission set (e.g., "com.example.authBasicFeatures")
759
+
- `opts` - Keyword list of options.
760
+
761
+
## Options
762
+
- `:aud` (optional) - Audience of API requests as a DID service reference
763
+
(e.g., "did:web:api.example.com#srvtype"). Supports wildcard (`"*"`).
764
+
- `:as_string` (optional) - If `true` (default), returns a scope string,
765
+
otherwise returns a Permission struct.
766
+
767
+
If `:as_string` is true a scope string is returned, otherwise the underlying
768
+
Permission struct is returned.
769
+
770
+
## Examples
771
+
772
+
# Include a permission set (as string)
773
+
iex> Atex.OAuth.Permission.include("com.example.authBasicFeatures")
774
+
"include:com.example.authBasicFeatures"
775
+
776
+
# Include a permission set with audience (as struct)
777
+
iex> Atex.OAuth.Permission.include("com.example.authFull", aud: "did:web:api.example.com#svc_chat", as_string: false)
778
+
%Atex.OAuth.Permission{
779
+
resource: "include",
780
+
positional: "com.example.authFull",
781
+
parameters: [{"aud", "did:web:api.example.com#svc_chat"}]
782
+
}
783
+
784
+
# Include a permission set with wildcard audience
785
+
iex> Atex.OAuth.Permission.include("app.example.authFull", aud: "*")
786
+
"include:app.example.authFull?aud=*"
787
+
788
+
"""
789
+
@spec include(String.t(), list(include_opt())) :: t() | String.t()
790
+
def include(nsid, opts \\ []) do
791
+
opts = Keyword.validate!(opts, aud: nil, as_string: true)
792
+
aud = Keyword.get(opts, :aud)
793
+
as_string = Keyword.get(opts, :as_string)
794
+
795
+
parameters = if !is_nil(aud), do: [{"aud", aud}], else: []
796
+
797
+
struct = %__MODULE__{
798
+
resource: "include",
799
+
positional: nsid,
800
+
parameters: parameters
801
+
}
802
+
803
+
if as_string, do: to_string(struct), else: struct
804
+
end
805
+
end
806
+
807
+
defimpl String.Chars, for: Atex.OAuth.Permission do
808
+
def to_string(permission), do: Atex.OAuth.Permission.to_string(permission)
809
+
end
+244
lib/atex/oauth/plug.ex
+244
lib/atex/oauth/plug.ex
···
1
+
defmodule Atex.OAuth.Plug do
2
+
@moduledoc """
3
+
Plug router for handling AT Protocol's OAuth flow.
4
+
5
+
This module provides three endpoints:
6
+
7
+
- `GET /login?handle=<handle>` - Initiates the OAuth authorization flow for a
8
+
given handle
9
+
- `GET /callback` - Handles the OAuth callback after user authorization
10
+
- `GET /client-metadata.json` - Serves the OAuth client metadata
11
+
12
+
## Usage
13
+
14
+
This module requires `Plug.Session` to be in your pipeline, as well as
15
+
`secret_key_base` to have been set on your connections. Ideally it should be
16
+
routed to via `Plug.Router.forward/2`, under a route like "/oauth".
17
+
18
+
The plug requires a `:callback` option that must be an MFA tuple (Module,
19
+
Function, Args). This callback is invoked after successful OAuth
20
+
authentication, receiving the connection with the authenticated session data.
21
+
22
+
## Error Handling
23
+
24
+
`Atex.OAuth.Error` exceptions are raised when errors occur during the OAuth
25
+
flow (e.g. an invalid handle is provided, or validation failed). You should
26
+
implement a `Plug.ErrorHandler` to catch and handle these exceptions
27
+
gracefully.
28
+
29
+
## Example
30
+
31
+
Example implementation showing how to set up the OAuth plug with proper
32
+
session handling, error handling, and a callback function.
33
+
34
+
defmodule ExampleOAuthPlug do
35
+
use Plug.Router
36
+
use Plug.ErrorHandler
37
+
38
+
plug :put_secret_key_base
39
+
40
+
plug Plug.Session,
41
+
store: :cookie,
42
+
key: "atex-oauth",
43
+
signing_salt: "signing-salt"
44
+
45
+
plug :match
46
+
plug :dispatch
47
+
48
+
forward "/oauth", to: Atex.OAuth.Plug, init_opts: [callback: {__MODULE__, :oauth_callback, []}]
49
+
50
+
def oauth_callback(conn) do
51
+
# Handle successful OAuth authentication
52
+
conn
53
+
|> put_resp_header("Location", "/dashboard")
54
+
|> resp(307, "")
55
+
|> send_resp()
56
+
end
57
+
58
+
def put_secret_key_base(conn, _) do
59
+
put_in(
60
+
conn.secret_key_base,
61
+
"very long key base with at least 64 bytes"
62
+
)
63
+
end
64
+
65
+
# Error handler for OAuth exceptions
66
+
@impl Plug.ErrorHandler
67
+
def handle_errors(conn, %{kind: :error, reason: %Atex.OAuth.Error{} = error, stack: _stack}) do
68
+
status = case error.reason do
69
+
reason when reason in [:missing_handle, :invalid_handle, :invalid_callback_request, :issuer_mismatch] -> 400
70
+
_ -> 500
71
+
end
72
+
73
+
conn
74
+
|> put_resp_content_type("text/plain")
75
+
|> send_resp(status, error.message)
76
+
end
77
+
78
+
# Fallback for other errors
79
+
def handle_errors(conn, %{kind: _kind, reason: _reason, stack: _stack}) do
80
+
send_resp(conn, conn.status, "Something went wrong")
81
+
end
82
+
end
83
+
84
+
## Session Storage
85
+
86
+
After successful authentication, the plug stores these in the session:
87
+
88
+
- `:tokens` - The access token response containing access_token,
89
+
refresh_token, did, and expires_at
90
+
- `:dpop_nonce` -
91
+
- `:dpop_key` - The DPoP JWK for generating DPoP proofs
92
+
"""
93
+
require Logger
94
+
use Plug.Router
95
+
require Plug.Router
96
+
alias Atex.OAuth
97
+
alias Atex.{IdentityResolver, IdentityResolver.DIDDocument}
98
+
99
+
@oauth_cookie_opts [path: "/", http_only: true, secure: true, same_site: "lax", max_age: 600]
100
+
@session_name :atex_session
101
+
102
+
def init(opts) do
103
+
callback = Keyword.get(opts, :callback, nil)
104
+
105
+
if !match?({_module, _function, _args}, callback) do
106
+
raise "expected callback to be a MFA tuple"
107
+
end
108
+
109
+
opts
110
+
end
111
+
112
+
def call(conn, opts) do
113
+
conn
114
+
|> put_private(:atex_oauth_opts, opts)
115
+
|> super(opts)
116
+
end
117
+
118
+
plug :match
119
+
plug :dispatch
120
+
121
+
get "/login" do
122
+
conn = fetch_query_params(conn)
123
+
handle = conn.query_params["handle"]
124
+
125
+
if !handle do
126
+
raise Atex.OAuth.Error,
127
+
message: "Handle query parameter is required",
128
+
reason: :missing_handle
129
+
end
130
+
131
+
case IdentityResolver.resolve(handle) do
132
+
{:ok, identity} ->
133
+
pds = DIDDocument.get_pds_endpoint(identity.document)
134
+
{:ok, authz_server} = OAuth.get_authorization_server(pds)
135
+
{:ok, authz_metadata} = OAuth.get_authorization_server_metadata(authz_server)
136
+
state = OAuth.create_nonce()
137
+
code_verifier = OAuth.create_nonce()
138
+
139
+
case OAuth.create_authorization_url(
140
+
authz_metadata,
141
+
state,
142
+
code_verifier,
143
+
handle
144
+
) do
145
+
{:ok, authz_url} ->
146
+
conn
147
+
|> put_resp_cookie("state", state, @oauth_cookie_opts)
148
+
|> put_resp_cookie("code_verifier", code_verifier, @oauth_cookie_opts)
149
+
|> put_resp_cookie("issuer", authz_metadata.issuer, @oauth_cookie_opts)
150
+
|> put_resp_header("location", authz_url)
151
+
|> send_resp(307, "")
152
+
153
+
{:error, _err} ->
154
+
raise Atex.OAuth.Error,
155
+
message: "Failed to create authorization URL",
156
+
reason: :authorization_url_failed
157
+
end
158
+
159
+
_err ->
160
+
raise Atex.OAuth.Error, message: "Invalid or unresolvable handle", reason: :invalid_handle
161
+
end
162
+
end
163
+
164
+
get "/client-metadata.json" do
165
+
conn
166
+
|> put_resp_content_type("application/json")
167
+
|> send_resp(200, JSON.encode_to_iodata!(OAuth.create_client_metadata()))
168
+
end
169
+
170
+
get "/callback" do
171
+
conn = conn |> fetch_query_params() |> fetch_session()
172
+
callback = Keyword.get(conn.private.atex_oauth_opts, :callback)
173
+
cookies = get_cookies(conn)
174
+
stored_state = cookies["state"]
175
+
stored_code_verifier = cookies["code_verifier"]
176
+
stored_issuer = cookies["issuer"]
177
+
178
+
code = conn.query_params["code"]
179
+
state = conn.query_params["state"]
180
+
181
+
if !stored_state || !stored_code_verifier || !stored_issuer || (!code || !state) ||
182
+
stored_state != state do
183
+
raise Atex.OAuth.Error,
184
+
message: "Invalid callback request: missing or mismatched state/code parameters",
185
+
reason: :invalid_callback_request
186
+
end
187
+
188
+
with {:ok, authz_metadata} <- OAuth.get_authorization_server_metadata(stored_issuer),
189
+
dpop_key <- JOSE.JWK.generate_key({:ec, "P-256"}),
190
+
{:ok, tokens, nonce} <-
191
+
OAuth.validate_authorization_code(
192
+
authz_metadata,
193
+
dpop_key,
194
+
code,
195
+
stored_code_verifier
196
+
),
197
+
{:ok, identity} <- IdentityResolver.resolve(tokens.did),
198
+
# Make sure pds' issuer matches the stored one (just in case)
199
+
pds <- DIDDocument.get_pds_endpoint(identity.document),
200
+
{:ok, authz_server} <- OAuth.get_authorization_server(pds),
201
+
true <- authz_server == stored_issuer do
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])
224
+
225
+
{:error, reason} ->
226
+
raise Atex.OAuth.Error,
227
+
message: "Failed to store OAuth session, reason: #{reason}",
228
+
reason: :session_store_failed
229
+
end
230
+
else
231
+
false ->
232
+
raise Atex.OAuth.Error,
233
+
message: "OAuth issuer does not match PDS' authorization server",
234
+
reason: :issuer_mismatch
235
+
236
+
_err ->
237
+
raise Atex.OAuth.Error,
238
+
message: "Failed to validate authorization code or token",
239
+
reason: :token_validation_failed
240
+
end
241
+
end
242
+
243
+
# TODO: logout route
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
+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
+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
+582
lib/atex/oauth.ex
+582
lib/atex/oauth.ex
···
1
+
defmodule Atex.OAuth do
2
+
@moduledoc """
3
+
OAuth 2.0 implementation for AT Protocol authentication.
4
+
5
+
This module provides utilities for implementing OAuth flows compliant with the
6
+
AT Protocol specification. It includes support for:
7
+
8
+
- Pushed Authorization Requests (PAR)
9
+
- DPoP (Demonstration of Proof of Possession) tokens
10
+
- JWT client assertions
11
+
- PKCE (Proof Key for Code Exchange)
12
+
- Token refresh
13
+
- Handle to PDS resolution
14
+
15
+
## Configuration
16
+
17
+
See `Atex.Config.OAuth` module for configuration documentation.
18
+
19
+
## Usage Example
20
+
21
+
iex> pds = "https://bsky.social"
22
+
iex> login_hint = "example.com"
23
+
iex> {:ok, authz_server} = Atex.OAuth.get_authorization_server(pds)
24
+
iex> {:ok, authz_metadata} = Atex.OAuth.get_authorization_server_metadata(authz_server)
25
+
iex> state = Atex.OAuth.create_nonce()
26
+
iex> code_verifier = Atex.OAuth.create_nonce()
27
+
iex> {:ok, auth_url} = Atex.OAuth.create_authorization_url(
28
+
authz_metadata,
29
+
state,
30
+
code_verifier,
31
+
login_hint
32
+
)
33
+
"""
34
+
35
+
@type authorization_metadata() :: %{
36
+
issuer: String.t(),
37
+
par_endpoint: String.t(),
38
+
token_endpoint: String.t(),
39
+
authorization_endpoint: String.t()
40
+
}
41
+
42
+
@type tokens() :: %{
43
+
access_token: String.t(),
44
+
refresh_token: String.t(),
45
+
did: String.t(),
46
+
expires_at: NaiveDateTime.t()
47
+
}
48
+
49
+
alias Atex.Config.OAuth, as: Config
50
+
51
+
@doc """
52
+
Get a map cnotaining the client metadata information needed for an
53
+
authorization server to validate this client.
54
+
"""
55
+
@spec create_client_metadata() :: map()
56
+
def create_client_metadata() do
57
+
key = Config.get_key()
58
+
{_, jwk} = key |> JOSE.JWK.to_public_map()
59
+
jwk = Map.merge(jwk, %{use: "sig", kid: key.fields["kid"]})
60
+
61
+
%{
62
+
client_id: Config.client_id(),
63
+
redirect_uris: [Config.redirect_uri() | Config.extra_redirect_uris()],
64
+
application_type: "web",
65
+
grant_types: ["authorization_code", "refresh_token"],
66
+
scope: Config.scopes(),
67
+
response_type: ["code"],
68
+
token_endpoint_auth_method: "private_key_jwt",
69
+
token_endpoint_auth_signing_alg: "ES256",
70
+
dpop_bound_access_tokens: true,
71
+
jwks: %{keys: [jwk]}
72
+
}
73
+
end
74
+
75
+
@doc """
76
+
Retrieves the configured JWT private key for signing client assertions.
77
+
78
+
Loads the private key from configuration, decodes the base64-encoded DER data,
79
+
and creates a JOSE JWK structure with the key ID field set.
80
+
81
+
## Returns
82
+
83
+
A `JOSE.JWK` struct containing the private key and key identifier.
84
+
85
+
## Raises
86
+
87
+
* `Application.Env.Error` if the private_key or key_id configuration is missing
88
+
89
+
## Examples
90
+
91
+
key = OAuth.get_key()
92
+
key = OAuth.get_key()
93
+
"""
94
+
@spec get_key() :: JOSE.JWK.t()
95
+
def get_key(), do: Config.get_key()
96
+
97
+
@doc false
98
+
@spec random_b64(integer()) :: String.t()
99
+
def random_b64(length) do
100
+
:crypto.strong_rand_bytes(length)
101
+
|> Base.url_encode64(padding: false)
102
+
end
103
+
104
+
@doc false
105
+
@spec create_nonce() :: String.t()
106
+
def create_nonce(), do: random_b64(32)
107
+
108
+
@doc """
109
+
Create an OAuth authorization URL for a PDS.
110
+
111
+
Submits a PAR request to the authorization server and constructs the
112
+
authorization URL with the returned request URI. Supports PKCE, DPoP, and
113
+
client assertions as required by the AT Protocol.
114
+
115
+
## Parameters
116
+
117
+
- `authz_metadata` - Authorization server metadata containing endpoints, fetched from `get_authorization_server_metadata/1`
118
+
- `state` - Random token for session validation
119
+
- `code_verifier` - PKCE code verifier
120
+
- `login_hint` - User identifier (handle or DID) for pre-filled login
121
+
122
+
## Returns
123
+
124
+
- `{:ok, authorization_url}` - Successfully created authorization URL
125
+
- `{:ok, :invalid_par_response}` - Server respondend incorrectly to the request
126
+
- `{:error, reason}` - Error creating authorization URL
127
+
"""
128
+
@spec create_authorization_url(
129
+
authorization_metadata(),
130
+
String.t(),
131
+
String.t(),
132
+
String.t()
133
+
) :: {:ok, String.t()} | {:error, any()}
134
+
def create_authorization_url(
135
+
authz_metadata,
136
+
state,
137
+
code_verifier,
138
+
login_hint
139
+
) do
140
+
code_challenge = :crypto.hash(:sha256, code_verifier) |> Base.url_encode64(padding: false)
141
+
key = get_key()
142
+
143
+
client_assertion =
144
+
create_client_assertion(key, Config.client_id(), authz_metadata.issuer)
145
+
146
+
body =
147
+
%{
148
+
response_type: "code",
149
+
client_id: Config.client_id(),
150
+
redirect_uri: Config.redirect_uri(),
151
+
state: state,
152
+
code_challenge_method: "S256",
153
+
code_challenge: code_challenge,
154
+
scope: Config.scopes(),
155
+
client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
156
+
client_assertion: client_assertion,
157
+
login_hint: login_hint
158
+
}
159
+
160
+
case Req.post(authz_metadata.par_endpoint, form: body) do
161
+
{:ok, %{body: %{"request_uri" => request_uri}}} ->
162
+
query =
163
+
%{client_id: Config.client_id(), request_uri: request_uri}
164
+
|> URI.encode_query()
165
+
166
+
{:ok, "#{authz_metadata.authorization_endpoint}?#{query}"}
167
+
168
+
{:ok, _} ->
169
+
{:error, :invalid_par_response}
170
+
171
+
err ->
172
+
err
173
+
end
174
+
end
175
+
176
+
@doc """
177
+
Exchange an OAuth authorization code for a set of access and refresh tokens.
178
+
179
+
Validates the authorization code by submitting it to the token endpoint along with
180
+
the PKCE code verifier and client assertion. Returns access tokens for making authenticated
181
+
requests to the relevant user's PDS.
182
+
183
+
## Parameters
184
+
185
+
- `authz_metadata` - Authorization server metadata containing token endpoint
186
+
- `dpop_key` - JWK for DPoP token generation
187
+
- `code` - Authorization code from OAuth callback
188
+
- `code_verifier` - PKCE code verifier from authorization flow
189
+
190
+
## Returns
191
+
192
+
- `{:ok, tokens, nonce}` - Successfully obtained tokens with returned DPoP nonce
193
+
- `{:error, reason}` - Error exchanging code for tokens
194
+
"""
195
+
@spec validate_authorization_code(
196
+
authorization_metadata(),
197
+
JOSE.JWK.t(),
198
+
String.t(),
199
+
String.t()
200
+
) :: {:ok, tokens(), String.t()} | {:error, any()}
201
+
def validate_authorization_code(
202
+
authz_metadata,
203
+
dpop_key,
204
+
code,
205
+
code_verifier
206
+
) do
207
+
key = get_key()
208
+
209
+
client_assertion =
210
+
create_client_assertion(key, Config.client_id(), authz_metadata.issuer)
211
+
212
+
body =
213
+
%{
214
+
grant_type: "authorization_code",
215
+
client_id: Config.client_id(),
216
+
redirect_uri: Config.redirect_uri(),
217
+
code: code,
218
+
code_verifier: code_verifier,
219
+
client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
220
+
client_assertion: client_assertion
221
+
}
222
+
223
+
Req.new(method: :post, url: authz_metadata.token_endpoint, form: body)
224
+
|> send_oauth_dpop_request(dpop_key)
225
+
|> case do
226
+
{:ok,
227
+
%{
228
+
"access_token" => access_token,
229
+
"refresh_token" => refresh_token,
230
+
"expires_in" => expires_in,
231
+
"sub" => did
232
+
}, nonce} ->
233
+
expires_at = NaiveDateTime.utc_now() |> NaiveDateTime.add(expires_in, :second)
234
+
235
+
{:ok,
236
+
%{
237
+
access_token: access_token,
238
+
refresh_token: refresh_token,
239
+
did: did,
240
+
expires_at: expires_at
241
+
}, nonce}
242
+
243
+
err ->
244
+
err
245
+
end
246
+
end
247
+
248
+
def refresh_token(refresh_token, dpop_key, issuer, token_endpoint) do
249
+
key = get_key()
250
+
251
+
client_assertion =
252
+
create_client_assertion(key, Config.client_id(), issuer)
253
+
254
+
body = %{
255
+
grant_type: "refresh_token",
256
+
refresh_token: refresh_token,
257
+
client_id: Config.client_id(),
258
+
client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
259
+
client_assertion: client_assertion
260
+
}
261
+
262
+
Req.new(method: :post, url: token_endpoint, form: body)
263
+
|> send_oauth_dpop_request(dpop_key)
264
+
|> case do
265
+
{:ok,
266
+
%{
267
+
"access_token" => access_token,
268
+
"refresh_token" => refresh_token,
269
+
"expires_in" => expires_in,
270
+
"sub" => did
271
+
}, nonce} ->
272
+
expires_at = NaiveDateTime.utc_now() |> NaiveDateTime.add(expires_in, :second)
273
+
274
+
{:ok,
275
+
%{
276
+
access_token: access_token,
277
+
refresh_token: refresh_token,
278
+
did: did,
279
+
expires_at: expires_at
280
+
}, nonce}
281
+
282
+
err ->
283
+
err
284
+
end
285
+
end
286
+
287
+
@doc """
288
+
Fetch the authorization server for a given Personal Data Server (PDS).
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
+
301
+
- `{:ok, authorization_server}` - Successfully discovered authorization
302
+
server URL
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
+
342
+
@doc """
343
+
Fetch the metadata for an OAuth authorization server.
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
+
357
+
- `{:ok, metadata}` - Successfully retrieved authorization server metadata
358
+
- `{:error, :invalid_metadata}` - Server returned invalid metadata
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
+
421
+
@spec send_oauth_dpop_request(Req.Request.t(), JOSE.JWK.t(), String.t() | nil) ::
422
+
{:ok, map(), String.t()} | {:error, any(), String.t()}
423
+
def send_oauth_dpop_request(request, dpop_key, nonce \\ nil) do
424
+
dpop_token = create_dpop_token(dpop_key, request, nonce)
425
+
426
+
request
427
+
|> Req.Request.put_header("dpop", dpop_token)
428
+
|> Req.request()
429
+
|> case do
430
+
{:ok, resp} ->
431
+
dpop_nonce =
432
+
case resp.headers["dpop-nonce"] do
433
+
[new_nonce | _] -> new_nonce
434
+
_ -> nonce
435
+
end
436
+
437
+
cond do
438
+
resp.status == 200 ->
439
+
{:ok, resp.body, dpop_nonce}
440
+
441
+
resp.body["error"] === "use_dpop_nonce" ->
442
+
dpop_token = create_dpop_token(dpop_key, request, dpop_nonce)
443
+
444
+
request
445
+
|> Req.Request.put_header("dpop", dpop_token)
446
+
|> Req.request()
447
+
|> case do
448
+
{:ok, %{status: 200, body: body}} ->
449
+
{:ok, body, dpop_nonce}
450
+
451
+
{:ok, %{body: %{"error" => error, "error_description" => error_description}}} ->
452
+
{:error, {:oauth_error, error, error_description}, dpop_nonce}
453
+
454
+
{:ok, _} ->
455
+
{:error, :unexpected_response, dpop_nonce}
456
+
457
+
{:error, err} ->
458
+
{:error, err, dpop_nonce}
459
+
end
460
+
461
+
true ->
462
+
{:error, {:oauth_error, resp.body["error"], resp.body["error_description"]},
463
+
dpop_nonce}
464
+
end
465
+
466
+
{:error, err} ->
467
+
{:error, err, nonce}
468
+
end
469
+
end
470
+
471
+
@spec request_protected_dpop_resource(
472
+
Req.Request.t(),
473
+
String.t(),
474
+
String.t(),
475
+
JOSE.JWK.t(),
476
+
String.t() | nil
477
+
) :: {:ok, Req.Response.t(), String.t() | nil} | {:error, any()}
478
+
def request_protected_dpop_resource(request, issuer, access_token, dpop_key, nonce \\ nil) do
479
+
access_token_hash = :crypto.hash(:sha256, access_token) |> Base.url_encode64(padding: false)
480
+
# access_token_hash = Base.url_encode64(access_token, padding: false)
481
+
482
+
dpop_token =
483
+
create_dpop_token(dpop_key, request, nonce, %{iss: issuer, ath: access_token_hash})
484
+
485
+
request
486
+
|> Req.Request.put_header("dpop", dpop_token)
487
+
|> Req.request()
488
+
|> case do
489
+
{:ok, resp} ->
490
+
dpop_nonce =
491
+
case resp.headers["dpop-nonce"] do
492
+
[new_nonce | _] -> new_nonce
493
+
_ -> nonce
494
+
end
495
+
496
+
www_authenticate = Req.Response.get_header(resp, "www-authenticate")
497
+
498
+
www_dpop_problem =
499
+
www_authenticate != [] && String.starts_with?(Enum.at(www_authenticate, 0), "DPoP")
500
+
501
+
if resp.status != 401 || !www_dpop_problem do
502
+
{:ok, resp, dpop_nonce}
503
+
else
504
+
dpop_token =
505
+
create_dpop_token(dpop_key, request, dpop_nonce, %{
506
+
iss: issuer,
507
+
ath: access_token_hash
508
+
})
509
+
510
+
request
511
+
|> Req.Request.put_header("dpop", dpop_token)
512
+
|> Req.request()
513
+
|> case do
514
+
{:ok, resp} ->
515
+
dpop_nonce =
516
+
case resp.headers["dpop-nonce"] do
517
+
[new_nonce | _] -> new_nonce
518
+
_ -> dpop_nonce
519
+
end
520
+
521
+
{:ok, resp, dpop_nonce}
522
+
523
+
err ->
524
+
err
525
+
end
526
+
end
527
+
528
+
err ->
529
+
err
530
+
end
531
+
end
532
+
533
+
@spec create_client_assertion(JOSE.JWK.t(), String.t(), String.t()) :: String.t()
534
+
def create_client_assertion(jwk, client_id, issuer) do
535
+
iat = System.os_time(:second)
536
+
jti = random_b64(20)
537
+
jws = %{"alg" => "ES256", "kid" => jwk.fields["kid"]}
538
+
539
+
jwt = %{
540
+
iss: client_id,
541
+
sub: client_id,
542
+
aud: issuer,
543
+
jti: jti,
544
+
iat: iat,
545
+
exp: iat + 60
546
+
}
547
+
548
+
JOSE.JWT.sign(jwk, jws, jwt)
549
+
|> JOSE.JWS.compact()
550
+
|> elem(1)
551
+
end
552
+
553
+
@spec create_dpop_token(JOSE.JWK.t(), Req.Request.t(), any(), map()) :: String.t()
554
+
def create_dpop_token(jwk, request, nonce \\ nil, attrs \\ %{}) do
555
+
iat = System.os_time(:second)
556
+
jti = random_b64(20)
557
+
{_, public_jwk} = JOSE.JWK.to_public_map(jwk)
558
+
jws = %{"alg" => "ES256", "typ" => "dpop+jwt", "jwk" => public_jwk}
559
+
[request_url | _] = request.url |> to_string() |> String.split("?")
560
+
561
+
jwt =
562
+
Map.merge(attrs, %{
563
+
jti: jti,
564
+
htm: atom_to_upcase_string(request.method),
565
+
htu: request_url,
566
+
iat: iat
567
+
})
568
+
|> then(fn m ->
569
+
if nonce, do: Map.put(m, :nonce, nonce), else: m
570
+
end)
571
+
572
+
JOSE.JWT.sign(jwk, jws, jwt)
573
+
|> JOSE.JWS.compact()
574
+
|> elem(1)
575
+
end
576
+
577
+
@doc false
578
+
@spec atom_to_upcase_string(atom()) :: String.t()
579
+
def atom_to_upcase_string(atom) do
580
+
atom |> to_string() |> String.upcase()
581
+
end
582
+
end
+40
lib/atex/peri.ex
+40
lib/atex/peri.ex
···
1
+
defmodule Atex.Peri do
2
+
@moduledoc """
3
+
Custom validators for Peri, for use within atex.
4
+
"""
5
+
6
+
def uri, do: {:custom, &validate_uri/1}
7
+
def did, do: {:string, {:regex, ~r/^did:[a-z]+:[a-zA-Z0-9._:%-]*[a-zA-Z0-9._-]$/}}
8
+
9
+
defp validate_uri(uri) when is_binary(uri) do
10
+
case URI.new(uri) do
11
+
{:ok, _} -> :ok
12
+
{:error, _} -> {:error, "must be a valid URI", [uri: uri]}
13
+
end
14
+
end
15
+
16
+
defp validate_uri(uri), do: {:error, "must be a valid URI", [uri: uri]}
17
+
18
+
def validate_map(value, schema, extra_keys_schema) when is_map(value) and is_map(schema) do
19
+
extra_keys =
20
+
Enum.reduce(Map.keys(schema), MapSet.new(Map.keys(value)), fn key, acc ->
21
+
acc |> MapSet.delete(key) |> MapSet.delete(to_string(key))
22
+
end)
23
+
24
+
extra_data =
25
+
value
26
+
|> Enum.filter(fn {key, _} -> MapSet.member?(extra_keys, key) end)
27
+
|> Map.new()
28
+
29
+
with {:ok, schema_data} <- Peri.validate(schema, value),
30
+
{:ok, extra_data} <- Peri.validate(extra_keys_schema, extra_data) do
31
+
{:ok, Map.merge(schema_data, extra_data)}
32
+
else
33
+
{:error, %Peri.Error{} = err} -> {:error, [err]}
34
+
e -> e
35
+
end
36
+
end
37
+
38
+
def validate_map(value, _schema, _extra_keys_schema),
39
+
do: {:error, "must be a map", [value: value]}
40
+
end
+189
lib/atex/tid.ex
+189
lib/atex/tid.ex
···
1
+
defmodule Atex.TID do
2
+
@moduledoc """
3
+
Struct and helper functions for dealing with AT Protocol TIDs (Timestamp
4
+
Identifiers), a 13-character string representation of a 64-bit number
5
+
comprised of a Unix timestamp (in microsecond precision) and a random "clock
6
+
identifier" to help avoid collisions.
7
+
8
+
ATProto spec: https://atproto.com/specs/tid
9
+
10
+
TID strings are always 13 characters long. All bits in the 64-bit number are
11
+
encoded, essentially meaning that the string is padded with "2" if necessary,
12
+
(the 0th character in the base32-sortable alphabet).
13
+
"""
14
+
import Bitwise
15
+
alias Atex.Base32Sortable
16
+
use TypedStruct
17
+
18
+
@re ~r/^[234567abcdefghij][234567abcdefghijklmnopqrstuvwxyz]{12}$/
19
+
20
+
@typedoc """
21
+
A Unix timestamp representing when the TID was created.
22
+
"""
23
+
@type timestamp() :: integer()
24
+
25
+
@typedoc """
26
+
An integer to be used for the lower 10 bits of the TID.
27
+
"""
28
+
@type clock_id() :: 0..1023
29
+
30
+
typedstruct enforce: true do
31
+
field :timestamp, timestamp()
32
+
field :clock_id, clock_id()
33
+
end
34
+
35
+
@doc """
36
+
Returns a TID for the current moment in time, along with a random clock ID.
37
+
"""
38
+
@spec now() :: t()
39
+
def now,
40
+
do: %__MODULE__{
41
+
timestamp: DateTime.utc_now(:microsecond) |> DateTime.to_unix(:microsecond),
42
+
clock_id: gen_clock_id()
43
+
}
44
+
45
+
@doc """
46
+
Create a new TID from a `DateTime` or an integer representing a Unix timestamp in microseconds.
47
+
48
+
If `clock_id` isn't provided, a random one will be generated.
49
+
"""
50
+
@spec new(DateTime.t() | integer(), integer() | nil) :: t()
51
+
def new(source, clock_id \\ nil)
52
+
53
+
def new(%DateTime{} = datetime, clock_id),
54
+
do: %__MODULE__{
55
+
timestamp: DateTime.to_unix(datetime, :microsecond),
56
+
clock_id: clock_id || gen_clock_id()
57
+
}
58
+
59
+
def new(unix, clock_id) when is_integer(unix),
60
+
do: %__MODULE__{timestamp: unix, clock_id: clock_id || gen_clock_id()}
61
+
62
+
@doc """
63
+
Convert a TID struct to an instance of `DateTime`.
64
+
"""
65
+
def to_datetime(%__MODULE__{} = tid), do: DateTime.from_unix(tid.timestamp, :microsecond)
66
+
67
+
@doc """
68
+
Generate a random integer to be used as a `clock_id`.
69
+
"""
70
+
@spec gen_clock_id() :: clock_id()
71
+
def gen_clock_id, do: :rand.uniform(1024) - 1
72
+
73
+
@doc """
74
+
Decode a TID string into an `Atex.TID` struct, returning an error if it's invalid.
75
+
76
+
## Examples
77
+
78
+
Syntactically valid TIDs:
79
+
80
+
iex> Atex.TID.decode("3jzfcijpj2z2a")
81
+
{:ok, %Atex.TID{clock_id: 6, timestamp: 1688137381887007}}
82
+
83
+
iex> Atex.TID.decode("7777777777777")
84
+
{:ok, %Atex.TID{clock_id: 165, timestamp: 5811096293381285}}
85
+
86
+
iex> Atex.TID.decode("3zzzzzzzzzzzz")
87
+
{:ok, %Atex.TID{clock_id: 1023, timestamp: 2251799813685247}}
88
+
89
+
iex> Atex.TID.decode("2222222222222")
90
+
{:ok, %Atex.TID{clock_id: 0, timestamp: 0}}
91
+
92
+
Invalid TIDs:
93
+
94
+
# not base32
95
+
iex> Atex.TID.decode("3jzfcijpj2z21")
96
+
:error
97
+
iex> Atex.TID.decode("0000000000000")
98
+
:error
99
+
100
+
# case-sensitive
101
+
iex> Atex.TID.decode("3JZFCIJPJ2Z2A")
102
+
:error
103
+
104
+
# too long/short
105
+
iex> Atex.TID.decode("3jzfcijpj2z2aa")
106
+
:error
107
+
iex> Atex.TID.decode("3jzfcijpj2z2")
108
+
:error
109
+
iex> Atex.TID.decode("222")
110
+
:error
111
+
112
+
# legacy dash syntax *not* supported (TTTT-TTT-TTTT-CC)
113
+
iex> Atex.TID.decode("3jzf-cij-pj2z-2a")
114
+
:error
115
+
116
+
# high bit can't be set
117
+
iex> Atex.TID.decode("zzzzzzzzzzzzz")
118
+
:error
119
+
iex> Atex.TID.decode("kjzfcijpj2z2a")
120
+
:error
121
+
122
+
"""
123
+
@spec decode(String.t()) :: {:ok, t()} | :error
124
+
def decode(<<timestamp::binary-size(11), clock_id::binary-size(2)>> = tid) do
125
+
if match?(tid) do
126
+
timestamp = Base32Sortable.decode(timestamp)
127
+
clock_id = Base32Sortable.decode(clock_id)
128
+
129
+
{:ok,
130
+
%__MODULE__{
131
+
timestamp: timestamp,
132
+
clock_id: clock_id
133
+
}}
134
+
else
135
+
:error
136
+
end
137
+
end
138
+
139
+
def decode(_tid), do: :error
140
+
141
+
@doc """
142
+
Encode a TID struct into a string.
143
+
144
+
## Examples
145
+
146
+
iex> Atex.TID.encode(%Atex.TID{clock_id: 6, timestamp: 1688137381887007})
147
+
"3jzfcijpj2z2a"
148
+
149
+
iex> Atex.TID.encode(%Atex.TID{clock_id: 165, timestamp: 5811096293381285})
150
+
"7777777777777"
151
+
152
+
iex> Atex.TID.encode(%Atex.TID{clock_id: 1023, timestamp: 2251799813685247})
153
+
"3zzzzzzzzzzzz"
154
+
155
+
iex> Atex.TID.encode(%Atex.TID{clock_id: 0, timestamp: 0})
156
+
"2222222222222"
157
+
158
+
"""
159
+
@spec encode(t()) :: String.t()
160
+
def encode(%__MODULE__{} = tid) do
161
+
timestamp = tid.timestamp |> Base32Sortable.encode() |> String.pad_leading(11, "2")
162
+
clock_id = (tid.clock_id &&& 1023) |> Base32Sortable.encode() |> String.pad_leading(2, "2")
163
+
timestamp <> clock_id
164
+
end
165
+
166
+
@doc """
167
+
Check if a given string matches the format for a TID.
168
+
169
+
## Examples
170
+
171
+
iex> Atex.TID.match?("3jzfcijpj2z2a")
172
+
true
173
+
174
+
iex> Atex.TID.match?("2222222222222")
175
+
true
176
+
177
+
iex> Atex.TID.match?("banana")
178
+
false
179
+
180
+
iex> Atex.TID.match?("kjzfcijpj2z2a")
181
+
false
182
+
"""
183
+
@spec match?(String.t()) :: boolean()
184
+
def match?(value), do: Regex.match?(@re, value)
185
+
end
186
+
187
+
defimpl String.Chars, for: Atex.TID do
188
+
def to_string(tid), do: Atex.TID.encode(tid)
189
+
end
+31
lib/atex/xrpc/client.ex
+31
lib/atex/xrpc/client.ex
···
1
+
defmodule Atex.XRPC.Client do
2
+
@moduledoc """
3
+
Behaviour that defines the interface for XRPC clients.
4
+
5
+
This behaviour allows different types of clients (login-based, OAuth-based, etc.)
6
+
to implement authentication and request handling while maintaining a consistent interface.
7
+
8
+
Implementations must handle token refresh internally when requests fail due to
9
+
expired tokens, and return both the result and potentially updated client state.
10
+
"""
11
+
12
+
@type client :: struct()
13
+
@type request_opts :: keyword()
14
+
@type request_result :: {:ok, Req.Response.t(), client()} | {:error, any(), client()}
15
+
16
+
@doc """
17
+
Perform an authenticated HTTP GET request on an XRPC resource.
18
+
19
+
Implementations should handle token refresh if the request fails due to
20
+
expired authentication, returning both the response and the (potentially updated) client.
21
+
"""
22
+
@callback get(client(), String.t(), request_opts()) :: request_result()
23
+
24
+
@doc """
25
+
Perform an authenticated HTTP POST request on an XRPC resource.
26
+
27
+
Implementations should handle token refresh if the request fails due to
28
+
expired authentication, returning both the response and the (potentially updated) client.
29
+
"""
30
+
@callback post(client(), String.t(), request_opts()) :: request_result()
31
+
end
+148
lib/atex/xrpc/login_client.ex
+148
lib/atex/xrpc/login_client.ex
···
1
+
defmodule Atex.XRPC.LoginClient do
2
+
alias Atex.XRPC
3
+
use TypedStruct
4
+
5
+
@behaviour Atex.XRPC.Client
6
+
7
+
typedstruct do
8
+
field :endpoint, String.t(), enforce: true
9
+
field :access_token, String.t() | nil
10
+
field :refresh_token, String.t() | nil
11
+
end
12
+
13
+
@doc """
14
+
Create a new `Atex.XRPC.LoginClient` from an endpoint, and optionally an
15
+
existing access/refresh token.
16
+
17
+
Endpoint should be the base URL of a PDS, or an AppView in the case of
18
+
unauthenticated requests (like Bluesky's public API), e.g.
19
+
`https://bsky.social`.
20
+
"""
21
+
@spec new(String.t(), String.t() | nil, String.t() | nil) :: t()
22
+
def new(endpoint, access_token \\ nil, refresh_token \\ nil) do
23
+
%__MODULE__{endpoint: endpoint, access_token: access_token, refresh_token: refresh_token}
24
+
end
25
+
26
+
@doc """
27
+
Create a new `Atex.XRPC.LoginClient` by logging in with an `identifier` and
28
+
`password` to fetch an initial pair of access & refresh tokens.
29
+
30
+
Also supports providing a MFA token in the situation that is required.
31
+
32
+
Uses `com.atproto.server.createSession` under the hood, so `identifier` can be
33
+
either a handle or a DID.
34
+
35
+
## Examples
36
+
37
+
iex> Atex.XRPC.LoginClient.login("https://bsky.social", "example.com", "password123")
38
+
{:ok, %Atex.XRPC.LoginClient{...}}
39
+
"""
40
+
@spec login(String.t(), String.t(), String.t()) :: {:ok, t()} | {:error, any()}
41
+
@spec login(String.t(), String.t(), String.t(), String.t() | nil) ::
42
+
{:ok, t()} | {:error, any()}
43
+
def login(endpoint, identifier, password, auth_factor_token \\ nil) do
44
+
json =
45
+
%{identifier: identifier, password: password}
46
+
|> then(
47
+
&if auth_factor_token do
48
+
Map.merge(&1, %{authFactorToken: auth_factor_token})
49
+
else
50
+
&1
51
+
end
52
+
)
53
+
54
+
response = XRPC.unauthed_post(endpoint, "com.atproto.server.createSession", json: json)
55
+
56
+
case response do
57
+
{:ok, %{body: %{"accessJwt" => access_token, "refreshJwt" => refresh_token}}} ->
58
+
{:ok, new(endpoint, access_token, refresh_token)}
59
+
60
+
err ->
61
+
err
62
+
end
63
+
end
64
+
65
+
@doc """
66
+
Request a new `refresh_token` for the given client.
67
+
"""
68
+
@spec refresh(t()) :: {:ok, t()} | {:error, any()}
69
+
def refresh(%__MODULE__{endpoint: endpoint, refresh_token: refresh_token} = client) do
70
+
request =
71
+
Req.new(method: :post, url: XRPC.url(endpoint, "com.atproto.server.refreshSession"))
72
+
|> put_auth(refresh_token)
73
+
74
+
case Req.request(request) do
75
+
{:ok, %{body: %{"accessJwt" => access_token, "refreshJwt" => refresh_token}}} ->
76
+
{:ok, %{client | access_token: access_token, refresh_token: refresh_token}}
77
+
78
+
{:ok, response} ->
79
+
{:error, response}
80
+
81
+
err ->
82
+
err
83
+
end
84
+
end
85
+
86
+
@impl true
87
+
def get(%__MODULE__{} = client, resource, opts \\ []) do
88
+
request(client, opts ++ [method: :get, url: XRPC.url(client.endpoint, resource)])
89
+
end
90
+
91
+
@impl true
92
+
def post(%__MODULE__{} = client, resource, opts \\ []) do
93
+
request(client, opts ++ [method: :post, url: XRPC.url(client.endpoint, resource)])
94
+
end
95
+
96
+
@spec request(t(), keyword()) :: {:ok, Req.Response.t(), t()} | {:error, any()}
97
+
defp request(client, opts) do
98
+
with {:ok, client} <- validate_client(client) do
99
+
request = opts |> Req.new() |> put_auth(client.access_token)
100
+
101
+
case Req.request(request) do
102
+
{:ok, %{status: 200} = response} ->
103
+
{:ok, response, client}
104
+
105
+
{:ok, response} ->
106
+
handle_failure(client, response, request)
107
+
108
+
err ->
109
+
err
110
+
end
111
+
end
112
+
end
113
+
114
+
@spec handle_failure(t(), Req.Response.t(), Req.Request.t()) ::
115
+
{:ok, Req.Response.t(), t()} | {:error, any()}
116
+
defp handle_failure(client, response, request) do
117
+
IO.inspect(response, label: "got failure")
118
+
119
+
if auth_error?(response.body) and client.refresh_token do
120
+
case refresh(client) do
121
+
{:ok, client} ->
122
+
case Req.request(put_auth(request, client.access_token)) do
123
+
{:ok, %{status: 200} = response} -> {:ok, response, client}
124
+
{:ok, response} -> {:error, response}
125
+
err -> err
126
+
end
127
+
128
+
err ->
129
+
err
130
+
end
131
+
else
132
+
{:error, response}
133
+
end
134
+
end
135
+
136
+
@spec validate_client(t()) :: {:ok, t()} | {:error, any()}
137
+
defp validate_client(%__MODULE__{access_token: nil}), do: {:error, :no_token}
138
+
defp validate_client(%__MODULE__{} = client), do: {:ok, client}
139
+
140
+
@spec auth_error?(body :: Req.Response.t()) :: boolean()
141
+
defp auth_error?(%{status: status}) when status in [401, 403], do: true
142
+
defp auth_error?(%{body: %{"error" => "InvalidToken"}}), do: true
143
+
defp auth_error?(_response), do: false
144
+
145
+
@spec put_auth(Req.Request.t(), String.t()) :: Req.Request.t()
146
+
defp put_auth(request, token),
147
+
do: Req.Request.put_header(request, "authorization", "Bearer #{token}")
148
+
end
+292
lib/atex/xrpc/oauth_client.ex
+292
lib/atex/xrpc/oauth_client.ex
···
1
+
defmodule Atex.XRPC.OAuthClient do
2
+
@moduledoc """
3
+
OAuth client for making authenticated XRPC requests to AT Protocol servers.
4
+
5
+
The client contains a user's DID and talks to `Atex.OAuth.SessionStore` to
6
+
retrieve sessions internally to make requests. As a result, it will only work
7
+
for users that have gone through an OAuth flow; see `Atex.OAuth.Plug` for an
8
+
existing method of doing that.
9
+
10
+
The entire OAuth session lifecycle is handled transparently, with the access
11
+
token being refreshed automatically as required.
12
+
13
+
## Usage
14
+
15
+
# Create from an existing OAuth session
16
+
{:ok, client} = Atex.XRPC.OAuthClient.new("did:plc:abc123")
17
+
18
+
# Or extract from a Plug.Conn after OAuth flow
19
+
{:ok, client} = Atex.XRPC.OAuthClient.from_conn(conn)
20
+
21
+
# Make XRPC requests
22
+
{:ok, response, client} = Atex.XRPC.get(client, "com.atproto.repo.listRecords")
23
+
"""
24
+
25
+
alias Atex.OAuth
26
+
use TypedStruct
27
+
28
+
@behaviour Atex.XRPC.Client
29
+
30
+
typedstruct enforce: true do
31
+
field :did, String.t()
32
+
end
33
+
34
+
@doc """
35
+
Create a new OAuthClient from a DID.
36
+
37
+
Validates that an OAuth session exists for the given DID in the session store
38
+
before returning the client struct.
39
+
40
+
## Examples
41
+
42
+
iex> Atex.XRPC.OAuthClient.new("did:plc:abc123")
43
+
{:ok, %Atex.XRPC.OAuthClient{did: "did:plc:abc123"}}
44
+
45
+
iex> Atex.XRPC.OAuthClient.new("did:plc:nosession")
46
+
{:error, :not_found}
47
+
48
+
"""
49
+
@spec new(String.t()) :: {:ok, t()} | {:error, atom()}
50
+
def new(did) do
51
+
# Make sure session exists before returning a struct
52
+
case Atex.OAuth.SessionStore.get(did) do
53
+
{:ok, _session} ->
54
+
{:ok, %__MODULE__{did: did}}
55
+
56
+
err ->
57
+
err
58
+
end
59
+
end
60
+
61
+
@doc """
62
+
Create an OAuthClient from a `Plug.Conn`.
63
+
64
+
Extracts the DID from the session (stored under `:atex_session` key) and validates
65
+
that the OAuth session is still valid. If the token is expired or expiring soon,
66
+
it attempts to refresh it.
67
+
68
+
Requires the conn to have passed through `Plug.Session` and `Plug.Conn.fetch_session/2`.
69
+
70
+
## Returns
71
+
72
+
- `{:ok, client}` - Successfully created client
73
+
- `{:error, :reauth}` - Session exists but refresh failed, user needs to re-authenticate
74
+
- `:error` - No session found in conn
75
+
76
+
## Examples
77
+
78
+
# After OAuth flow completes
79
+
conn = Plug.Conn.put_session(conn, :atex_session, "did:plc:abc123")
80
+
{:ok, client} = Atex.XRPC.OAuthClient.from_conn(conn)
81
+
82
+
"""
83
+
@spec from_conn(Plug.Conn.t()) :: {:ok, t()} | :error | {:error, atom()}
84
+
def from_conn(%Plug.Conn{} = conn) do
85
+
oauth_did = Plug.Conn.get_session(conn, :atex_session)
86
+
87
+
case oauth_did do
88
+
did when is_binary(did) ->
89
+
client = %__MODULE__{did: did}
90
+
91
+
with_session_lock(client, fn ->
92
+
case maybe_refresh(client) do
93
+
{:ok, _session} -> {:ok, client}
94
+
_ -> {:error, :reauth}
95
+
end
96
+
end)
97
+
98
+
_ ->
99
+
:error
100
+
end
101
+
end
102
+
103
+
@doc """
104
+
Ask the client's OAuth server for a new set of auth tokens.
105
+
106
+
Fetches the session, refreshes the tokens, creates a new session with the
107
+
updated tokens, stores it, and returns the new session.
108
+
109
+
You shouldn't need to call this manually for the most part, the client does
110
+
its best to refresh automatically when it needs to.
111
+
112
+
This function acquires a lock on the session to prevent concurrent refresh attempts.
113
+
"""
114
+
@spec refresh(client :: t()) :: {:ok, OAuth.Session.t()} | {:error, any()}
115
+
def refresh(%__MODULE__{} = client) do
116
+
with_session_lock(client, fn ->
117
+
do_refresh(client)
118
+
end)
119
+
end
120
+
121
+
@spec do_refresh(t()) :: {:ok, OAuth.Session.t()} | {:error, any()}
122
+
defp do_refresh(%__MODULE__{did: did}) do
123
+
with {:ok, session} <- OAuth.SessionStore.get(did),
124
+
{:ok, authz_server} <- OAuth.get_authorization_server(session.aud),
125
+
{:ok, %{token_endpoint: token_endpoint}} <-
126
+
OAuth.get_authorization_server_metadata(authz_server) do
127
+
case OAuth.refresh_token(
128
+
session.refresh_token,
129
+
session.dpop_key,
130
+
session.iss,
131
+
token_endpoint
132
+
) do
133
+
{:ok, tokens, nonce} ->
134
+
new_session = %OAuth.Session{
135
+
iss: session.iss,
136
+
aud: session.aud,
137
+
sub: tokens.did,
138
+
access_token: tokens.access_token,
139
+
refresh_token: tokens.refresh_token,
140
+
expires_at: tokens.expires_at,
141
+
dpop_key: session.dpop_key,
142
+
dpop_nonce: nonce
143
+
}
144
+
145
+
case OAuth.SessionStore.update(new_session) do
146
+
:ok -> {:ok, new_session}
147
+
err -> err
148
+
end
149
+
150
+
err ->
151
+
err
152
+
end
153
+
end
154
+
end
155
+
156
+
@spec maybe_refresh(t(), integer()) :: {:ok, OAuth.Session.t()} | {:error, any()}
157
+
defp maybe_refresh(%__MODULE__{did: did} = client, buffer_minutes \\ 5) do
158
+
with {:ok, session} <- OAuth.SessionStore.get(did) do
159
+
if token_expiring_soon?(session.expires_at, buffer_minutes) do
160
+
do_refresh(client)
161
+
else
162
+
{:ok, session}
163
+
end
164
+
end
165
+
end
166
+
167
+
@spec token_expiring_soon?(NaiveDateTime.t(), integer()) :: boolean()
168
+
defp token_expiring_soon?(expires_at, buffer_minutes) do
169
+
now = NaiveDateTime.utc_now()
170
+
expiry_threshold = NaiveDateTime.add(now, buffer_minutes * 60, :second)
171
+
172
+
NaiveDateTime.compare(expires_at, expiry_threshold) in [:lt, :eq]
173
+
end
174
+
175
+
@doc """
176
+
Make a GET request to an XRPC endpoint.
177
+
178
+
See `Atex.XRPC.get/3` for details.
179
+
"""
180
+
@impl true
181
+
def get(%__MODULE__{} = client, resource, opts \\ []) do
182
+
# TODO: Keyword.valiate to make sure :method isn't passed?
183
+
request(client, resource, opts ++ [method: :get])
184
+
end
185
+
186
+
@doc """
187
+
Make a POST request to an XRPC endpoint.
188
+
189
+
See `Atex.XRPC.post/3` for details.
190
+
"""
191
+
@impl true
192
+
def post(%__MODULE__{} = client, resource, opts \\ []) do
193
+
# Ditto
194
+
request(client, resource, opts ++ [method: :post])
195
+
end
196
+
197
+
defp request(%__MODULE__{} = client, resource, opts) do
198
+
with_session_lock(client, fn ->
199
+
case maybe_refresh(client) do
200
+
{:ok, session} ->
201
+
url = Atex.XRPC.url(session.aud, resource)
202
+
203
+
request =
204
+
opts
205
+
|> Keyword.put(:url, url)
206
+
|> Req.new()
207
+
|> Req.Request.put_header("authorization", "DPoP #{session.access_token}")
208
+
209
+
case OAuth.request_protected_dpop_resource(
210
+
request,
211
+
session.iss,
212
+
session.access_token,
213
+
session.dpop_key,
214
+
session.dpop_nonce
215
+
) do
216
+
{:ok, %{status: 200} = response, nonce} ->
217
+
update_session_nonce(session, nonce)
218
+
{:ok, response, client}
219
+
220
+
{:ok, response, nonce} ->
221
+
update_session_nonce(session, nonce)
222
+
handle_failure(client, request, response)
223
+
224
+
err ->
225
+
err
226
+
end
227
+
228
+
err ->
229
+
err
230
+
end
231
+
end)
232
+
end
233
+
234
+
# Execute a function with an exclusive lock on the session identified by the
235
+
# client's DID. This ensures that concurrent requests for the same user don't
236
+
# race during token refresh.
237
+
@spec with_session_lock(t(), (-> result)) :: result when result: any()
238
+
defp with_session_lock(%__MODULE__{did: did}, fun) do
239
+
Mutex.with_lock(Atex.SessionMutex, did, fun)
240
+
end
241
+
242
+
defp handle_failure(client, request, response) do
243
+
if auth_error?(response) do
244
+
case do_refresh(client) do
245
+
{:ok, session} ->
246
+
case OAuth.request_protected_dpop_resource(
247
+
request,
248
+
session.iss,
249
+
session.access_token,
250
+
session.dpop_key,
251
+
session.dpop_nonce
252
+
) do
253
+
{:ok, %{status: 200} = response, nonce} ->
254
+
update_session_nonce(session, nonce)
255
+
{:ok, response, client}
256
+
257
+
{:ok, response, _nonce} ->
258
+
if auth_error?(response) do
259
+
# We tried to refresh the token once but it's still failing
260
+
# Clear session and prompt dev to reauth or something
261
+
OAuth.SessionStore.delete(session)
262
+
{:error, response, :expired}
263
+
else
264
+
{:error, response, client}
265
+
end
266
+
267
+
err ->
268
+
err
269
+
end
270
+
271
+
err ->
272
+
err
273
+
end
274
+
else
275
+
{:error, response, client}
276
+
end
277
+
end
278
+
279
+
@spec auth_error?(Req.Response.t()) :: boolean()
280
+
defp auth_error?(%{status: 401, headers: %{"www-authenticate" => [www_auth]}}),
281
+
do:
282
+
(String.starts_with?(www_auth, "Bearer") or String.starts_with?(www_auth, "DPoP")) and
283
+
String.contains?(www_auth, "error=\"invalid_token\"")
284
+
285
+
defp auth_error?(_resp), do: false
286
+
287
+
defp update_session_nonce(session, nonce) do
288
+
session = %{session | dpop_nonce: nonce}
289
+
:ok = OAuth.SessionStore.update(session)
290
+
session
291
+
end
292
+
end
+210
lib/atex/xrpc.ex
+210
lib/atex/xrpc.ex
···
1
+
defmodule Atex.XRPC do
2
+
@moduledoc """
3
+
XRPC client module for AT Protocol RPC calls.
4
+
5
+
This module provides both authenticated and unauthenticated access to AT Protocol
6
+
XRPC endpoints. The authenticated functions (`get/3`, `post/3`) work with any
7
+
client that implements the `Atex.XRPC.Client`.
8
+
9
+
## Example usage
10
+
11
+
# Login-based client
12
+
{:ok, client} = Atex.XRPC.LoginClient.login("https://bsky.social", "user.bsky.social", "password")
13
+
{:ok, response, client} = Atex.XRPC.get(client, "app.bsky.actor.getProfile", params: [actor: "user.bsky.social"])
14
+
15
+
# OAuth-based client
16
+
{:ok, oauth_client} = Atex.XRPC.OAuthClient.from_conn(conn)
17
+
{:ok, response, oauth_client} = Atex.XRPC.get(oauth_client, "app.bsky.actor.getProfile", params: [actor: "user.bsky.social"])
18
+
19
+
## Unauthenticated requests
20
+
21
+
Unauthenticated functions (`unauthed_get/3`, `unauthed_post/3`) do not require a client
22
+
and work directly with endpoints:
23
+
24
+
{:ok, response} = Atex.XRPC.unauthed_get("https://bsky.social", "com.atproto.sync.getHead", params: [did: "did:plc:..."])
25
+
"""
26
+
27
+
alias Atex.XRPC.Client
28
+
29
+
@doc """
30
+
Perform a HTTP GET on a XRPC resource. Called a "query" in lexicons.
31
+
32
+
Accepts any client that implements `Atex.XRPC.Client` and returns
33
+
both the response and the (potentially updated) client.
34
+
35
+
Can be called either with the XRPC operation name as a string, or with a lexicon
36
+
struct (generated via `deflexicon`) for type safety and automatic parameter/response handling.
37
+
38
+
When using a lexicon struct, the response body will be automatically converted to the
39
+
corresponding type if an Output struct exists for the lexicon.
40
+
41
+
## Examples
42
+
43
+
# Using string XRPC name
44
+
{:ok, response, client} =
45
+
Atex.XRPC.get(client, "app.bsky.actor.getProfile", params: [actor: "ovyerus.com"])
46
+
47
+
# Using lexicon struct with typed construction
48
+
{:ok, response, client} =
49
+
Atex.XRPC.get(client, %App.Bsky.Actor.GetProfile{
50
+
params: %App.Bsky.Actor.GetProfile.Params{actor: "ovyerus.com"}
51
+
})
52
+
"""
53
+
@spec get(Client.client(), String.t() | struct(), keyword()) ::
54
+
{:ok, Req.Response.t(), Client.client()}
55
+
| {:error, any(), Client.client()}
56
+
def get(client, name, opts \\ [])
57
+
58
+
def get(client, name, opts) when is_binary(name) do
59
+
client.__struct__.get(client, name, opts)
60
+
end
61
+
62
+
def get(client, %{__struct__: module} = query, opts) do
63
+
opts = if Map.get(query, :params), do: Keyword.put(opts, :params, query.params), else: opts
64
+
output_struct = Module.concat(module, Output)
65
+
output_exists = Code.ensure_loaded?(output_struct)
66
+
67
+
case client.__struct__.get(client, module.id(), opts) do
68
+
{:ok, %{status: 200} = response, client} ->
69
+
if output_exists do
70
+
case output_struct.from_json(response.body) do
71
+
{:ok, output} ->
72
+
{:ok, %{response | body: output}, client}
73
+
74
+
err ->
75
+
err
76
+
end
77
+
else
78
+
{:ok, response, client}
79
+
end
80
+
81
+
{:ok, _, _} = ok ->
82
+
ok
83
+
84
+
err ->
85
+
err
86
+
end
87
+
end
88
+
89
+
@doc """
90
+
Perform a HTTP POST on a XRPC resource. Called a "procedure" in lexicons.
91
+
92
+
Accepts any client that implements `Atex.XRPC.Client` and returns both the
93
+
response and the (potentially updated) client.
94
+
95
+
Can be called either with the XRPC operation name as a string, or with a
96
+
lexicon struct (generated via `deflexicon`) for type safety and automatic
97
+
input/parameter mapping.
98
+
99
+
When using a lexicon struct, the response body will be automatically converted
100
+
to the corresponding type if an Output struct exists for the lexicon.
101
+
102
+
## Examples
103
+
104
+
# Using string XRPC name
105
+
{:ok, response, client} =
106
+
Atex.XRPC.post(
107
+
client,
108
+
"com.atproto.repo.createRecord",
109
+
json: %{
110
+
repo: "did:plc:...",
111
+
collection: "app.bsky.feed.post",
112
+
rkey: Atex.TID.now() |> to_string(),
113
+
record: %{
114
+
text: "Hello World",
115
+
createdAt: DateTime.to_iso8601(DateTime.utc_now())
116
+
}
117
+
}
118
+
)
119
+
120
+
# Using lexicon struct with typed construction
121
+
{:ok, response, client} =
122
+
Atex.XRPC.post(client, %Com.Atproto.Repo.CreateRecord{
123
+
input: %Com.Atproto.Repo.CreateRecord.Input{
124
+
repo: "did:plc:...",
125
+
collection: "app.bsky.feed.post",
126
+
rkey: Atex.TID.now() |> to_string(),
127
+
record: %App.Bsky.Feed.Post{
128
+
text: "Hello World!",
129
+
createdAt: DateTime.to_iso8601(DateTime.utc_now())
130
+
}
131
+
}
132
+
})
133
+
"""
134
+
@spec post(Client.client(), String.t() | struct(), keyword()) ::
135
+
{:ok, Req.Response.t(), Client.client()}
136
+
| {:error, any(), Client.client()}
137
+
def post(client, name, opts \\ [])
138
+
139
+
def post(client, name, opts) when is_binary(name) do
140
+
client.__struct__.post(client, name, opts)
141
+
end
142
+
143
+
def post(client, %{__struct__: module} = procedure, opts) do
144
+
opts =
145
+
opts
146
+
|> then(
147
+
&if Map.get(procedure, :params), do: Keyword.put(&1, :params, procedure.params), else: &1
148
+
)
149
+
|> then(
150
+
&cond do
151
+
Map.get(procedure, :input) -> Keyword.put(&1, :json, procedure.input)
152
+
Map.get(procedure, :raw_input) -> Keyword.put(&1, :body, procedure.raw_input)
153
+
true -> &1
154
+
end
155
+
)
156
+
157
+
output_struct = Module.concat(module, Output)
158
+
output_exists = Code.ensure_loaded?(output_struct)
159
+
160
+
case client.__struct__.post(client, module.id(), opts) do
161
+
{:ok, %{status: 200} = response, client} ->
162
+
if output_exists do
163
+
case output_struct.from_json(response.body) do
164
+
{:ok, output} ->
165
+
{:ok, %{response | body: output}, client}
166
+
167
+
err ->
168
+
err
169
+
end
170
+
else
171
+
{:ok, response, client}
172
+
end
173
+
174
+
{:ok, _, _} = ok ->
175
+
ok
176
+
177
+
err ->
178
+
err
179
+
end
180
+
end
181
+
182
+
@doc """
183
+
Like `get/3` but is unauthenticated by default.
184
+
"""
185
+
@spec unauthed_get(String.t(), String.t(), keyword()) ::
186
+
{:ok, Req.Response.t()} | {:error, any()}
187
+
def unauthed_get(endpoint, name, opts \\ []) do
188
+
Req.get(url(endpoint, name), opts)
189
+
end
190
+
191
+
@doc """
192
+
Like `post/3` but is unauthenticated by default.
193
+
"""
194
+
@spec unauthed_post(String.t(), String.t(), keyword()) ::
195
+
{:ok, Req.Response.t()} | {:error, any()}
196
+
def unauthed_post(endpoint, name, opts \\ []) do
197
+
Req.post(url(endpoint, name), opts)
198
+
end
199
+
200
+
@doc """
201
+
Create an XRPC url based on an endpoint and a resource name.
202
+
203
+
## Example
204
+
205
+
iex> Atex.XRPC.url("https://bsky.app", "app.bsky.actor.getProfile")
206
+
"https://bsky.app/xrpc/app.bsky.actor.getProfile"
207
+
"""
208
+
@spec url(String.t(), String.t()) :: String.t()
209
+
def url(endpoint, resource) when is_binary(endpoint), do: "#{endpoint}/xrpc/#{resource}"
210
+
end
+74
lib/atproto/com/atproto/admin/defs.ex
+74
lib/atproto/com/atproto/admin/defs.ex
···
1
+
defmodule Com.Atproto.Admin.Defs do
2
+
@moduledoc false
3
+
use Atex.Lexicon
4
+
5
+
deflexicon(%{
6
+
"defs" => %{
7
+
"accountView" => %{
8
+
"properties" => %{
9
+
"deactivatedAt" => %{"format" => "datetime", "type" => "string"},
10
+
"did" => %{"format" => "did", "type" => "string"},
11
+
"email" => %{"type" => "string"},
12
+
"emailConfirmedAt" => %{"format" => "datetime", "type" => "string"},
13
+
"handle" => %{"format" => "handle", "type" => "string"},
14
+
"indexedAt" => %{"format" => "datetime", "type" => "string"},
15
+
"inviteNote" => %{"type" => "string"},
16
+
"invitedBy" => %{
17
+
"ref" => "com.atproto.server.defs#inviteCode",
18
+
"type" => "ref"
19
+
},
20
+
"invites" => %{
21
+
"items" => %{
22
+
"ref" => "com.atproto.server.defs#inviteCode",
23
+
"type" => "ref"
24
+
},
25
+
"type" => "array"
26
+
},
27
+
"invitesDisabled" => %{"type" => "boolean"},
28
+
"relatedRecords" => %{
29
+
"items" => %{"type" => "unknown"},
30
+
"type" => "array"
31
+
},
32
+
"threatSignatures" => %{
33
+
"items" => %{"ref" => "#threatSignature", "type" => "ref"},
34
+
"type" => "array"
35
+
}
36
+
},
37
+
"required" => ["did", "handle", "indexedAt"],
38
+
"type" => "object"
39
+
},
40
+
"repoBlobRef" => %{
41
+
"properties" => %{
42
+
"cid" => %{"format" => "cid", "type" => "string"},
43
+
"did" => %{"format" => "did", "type" => "string"},
44
+
"recordUri" => %{"format" => "at-uri", "type" => "string"}
45
+
},
46
+
"required" => ["did", "cid"],
47
+
"type" => "object"
48
+
},
49
+
"repoRef" => %{
50
+
"properties" => %{"did" => %{"format" => "did", "type" => "string"}},
51
+
"required" => ["did"],
52
+
"type" => "object"
53
+
},
54
+
"statusAttr" => %{
55
+
"properties" => %{
56
+
"applied" => %{"type" => "boolean"},
57
+
"ref" => %{"type" => "string"}
58
+
},
59
+
"required" => ["applied"],
60
+
"type" => "object"
61
+
},
62
+
"threatSignature" => %{
63
+
"properties" => %{
64
+
"property" => %{"type" => "string"},
65
+
"value" => %{"type" => "string"}
66
+
},
67
+
"required" => ["property", "value"],
68
+
"type" => "object"
69
+
}
70
+
},
71
+
"id" => "com.atproto.admin.defs",
72
+
"lexicon" => 1
73
+
})
74
+
end
+23
lib/atproto/com/atproto/admin/deleteAccount.ex
+23
lib/atproto/com/atproto/admin/deleteAccount.ex
···
1
+
defmodule Com.Atproto.Admin.DeleteAccount do
2
+
@moduledoc false
3
+
use Atex.Lexicon
4
+
5
+
deflexicon(%{
6
+
"defs" => %{
7
+
"main" => %{
8
+
"description" => "Delete a user account as an administrator.",
9
+
"input" => %{
10
+
"encoding" => "application/json",
11
+
"schema" => %{
12
+
"properties" => %{"did" => %{"format" => "did", "type" => "string"}},
13
+
"required" => ["did"],
14
+
"type" => "object"
15
+
}
16
+
},
17
+
"type" => "procedure"
18
+
}
19
+
},
20
+
"id" => "com.atproto.admin.deleteAccount",
21
+
"lexicon" => 1
22
+
})
23
+
end
+30
lib/atproto/com/atproto/admin/disableAccountInvites.ex
+30
lib/atproto/com/atproto/admin/disableAccountInvites.ex
···
1
+
defmodule Com.Atproto.Admin.DisableAccountInvites do
2
+
@moduledoc false
3
+
use Atex.Lexicon
4
+
5
+
deflexicon(%{
6
+
"defs" => %{
7
+
"main" => %{
8
+
"description" =>
9
+
"Disable an account from receiving new invite codes, but does not invalidate existing codes.",
10
+
"input" => %{
11
+
"encoding" => "application/json",
12
+
"schema" => %{
13
+
"properties" => %{
14
+
"account" => %{"format" => "did", "type" => "string"},
15
+
"note" => %{
16
+
"description" => "Optional reason for disabled invites.",
17
+
"type" => "string"
18
+
}
19
+
},
20
+
"required" => ["account"],
21
+
"type" => "object"
22
+
}
23
+
},
24
+
"type" => "procedure"
25
+
}
26
+
},
27
+
"id" => "com.atproto.admin.disableAccountInvites",
28
+
"lexicon" => 1
29
+
})
30
+
end
+26
lib/atproto/com/atproto/admin/disableInviteCodes.ex
+26
lib/atproto/com/atproto/admin/disableInviteCodes.ex
···
1
+
defmodule Com.Atproto.Admin.DisableInviteCodes do
2
+
@moduledoc false
3
+
use Atex.Lexicon
4
+
5
+
deflexicon(%{
6
+
"defs" => %{
7
+
"main" => %{
8
+
"description" =>
9
+
"Disable some set of codes and/or all codes associated with a set of users.",
10
+
"input" => %{
11
+
"encoding" => "application/json",
12
+
"schema" => %{
13
+
"properties" => %{
14
+
"accounts" => %{"items" => %{"type" => "string"}, "type" => "array"},
15
+
"codes" => %{"items" => %{"type" => "string"}, "type" => "array"}
16
+
},
17
+
"type" => "object"
18
+
}
19
+
},
20
+
"type" => "procedure"
21
+
}
22
+
},
23
+
"id" => "com.atproto.admin.disableInviteCodes",
24
+
"lexicon" => 1
25
+
})
26
+
end
+29
lib/atproto/com/atproto/admin/enableAccountInvites.ex
+29
lib/atproto/com/atproto/admin/enableAccountInvites.ex
···
1
+
defmodule Com.Atproto.Admin.EnableAccountInvites do
2
+
@moduledoc false
3
+
use Atex.Lexicon
4
+
5
+
deflexicon(%{
6
+
"defs" => %{
7
+
"main" => %{
8
+
"description" => "Re-enable an account's ability to receive invite codes.",
9
+
"input" => %{
10
+
"encoding" => "application/json",
11
+
"schema" => %{
12
+
"properties" => %{
13
+
"account" => %{"format" => "did", "type" => "string"},
14
+
"note" => %{
15
+
"description" => "Optional reason for enabled invites.",
16
+
"type" => "string"
17
+
}
18
+
},
19
+
"required" => ["account"],
20
+
"type" => "object"
21
+
}
22
+
},
23
+
"type" => "procedure"
24
+
}
25
+
},
26
+
"id" => "com.atproto.admin.enableAccountInvites",
27
+
"lexicon" => 1
28
+
})
29
+
end
+27
lib/atproto/com/atproto/admin/getAccountInfo.ex
+27
lib/atproto/com/atproto/admin/getAccountInfo.ex
···
1
+
defmodule Com.Atproto.Admin.GetAccountInfo do
2
+
@moduledoc false
3
+
use Atex.Lexicon
4
+
5
+
deflexicon(%{
6
+
"defs" => %{
7
+
"main" => %{
8
+
"description" => "Get details about an account.",
9
+
"output" => %{
10
+
"encoding" => "application/json",
11
+
"schema" => %{
12
+
"ref" => "com.atproto.admin.defs#accountView",
13
+
"type" => "ref"
14
+
}
15
+
},
16
+
"parameters" => %{
17
+
"properties" => %{"did" => %{"format" => "did", "type" => "string"}},
18
+
"required" => ["did"],
19
+
"type" => "params"
20
+
},
21
+
"type" => "query"
22
+
}
23
+
},
24
+
"id" => "com.atproto.admin.getAccountInfo",
25
+
"lexicon" => 1
26
+
})
27
+
end
+41
lib/atproto/com/atproto/admin/getAccountInfos.ex
+41
lib/atproto/com/atproto/admin/getAccountInfos.ex
···
1
+
defmodule Com.Atproto.Admin.GetAccountInfos do
2
+
@moduledoc false
3
+
use Atex.Lexicon
4
+
5
+
deflexicon(%{
6
+
"defs" => %{
7
+
"main" => %{
8
+
"description" => "Get details about some accounts.",
9
+
"output" => %{
10
+
"encoding" => "application/json",
11
+
"schema" => %{
12
+
"properties" => %{
13
+
"infos" => %{
14
+
"items" => %{
15
+
"ref" => "com.atproto.admin.defs#accountView",
16
+
"type" => "ref"
17
+
},
18
+
"type" => "array"
19
+
}
20
+
},
21
+
"required" => ["infos"],
22
+
"type" => "object"
23
+
}
24
+
},
25
+
"parameters" => %{
26
+
"properties" => %{
27
+
"dids" => %{
28
+
"items" => %{"format" => "did", "type" => "string"},
29
+
"type" => "array"
30
+
}
31
+
},
32
+
"required" => ["dids"],
33
+
"type" => "params"
34
+
},
35
+
"type" => "query"
36
+
}
37
+
},
38
+
"id" => "com.atproto.admin.getAccountInfos",
39
+
"lexicon" => 1
40
+
})
41
+
end
+49
lib/atproto/com/atproto/admin/getInviteCodes.ex
+49
lib/atproto/com/atproto/admin/getInviteCodes.ex
···
1
+
defmodule Com.Atproto.Admin.GetInviteCodes do
2
+
@moduledoc false
3
+
use Atex.Lexicon
4
+
5
+
deflexicon(%{
6
+
"defs" => %{
7
+
"main" => %{
8
+
"description" => "Get an admin view of invite codes.",
9
+
"output" => %{
10
+
"encoding" => "application/json",
11
+
"schema" => %{
12
+
"properties" => %{
13
+
"codes" => %{
14
+
"items" => %{
15
+
"ref" => "com.atproto.server.defs#inviteCode",
16
+
"type" => "ref"
17
+
},
18
+
"type" => "array"
19
+
},
20
+
"cursor" => %{"type" => "string"}
21
+
},
22
+
"required" => ["codes"],
23
+
"type" => "object"
24
+
}
25
+
},
26
+
"parameters" => %{
27
+
"properties" => %{
28
+
"cursor" => %{"type" => "string"},
29
+
"limit" => %{
30
+
"default" => 100,
31
+
"maximum" => 500,
32
+
"minimum" => 1,
33
+
"type" => "integer"
34
+
},
35
+
"sort" => %{
36
+
"default" => "recent",
37
+
"knownValues" => ["recent", "usage"],
38
+
"type" => "string"
39
+
}
40
+
},
41
+
"type" => "params"
42
+
},
43
+
"type" => "query"
44
+
}
45
+
},
46
+
"id" => "com.atproto.admin.getInviteCodes",
47
+
"lexicon" => 1
48
+
})
49
+
end
+49
lib/atproto/com/atproto/admin/getSubjectStatus.ex
+49
lib/atproto/com/atproto/admin/getSubjectStatus.ex
···
1
+
defmodule Com.Atproto.Admin.GetSubjectStatus do
2
+
@moduledoc false
3
+
use Atex.Lexicon
4
+
5
+
deflexicon(%{
6
+
"defs" => %{
7
+
"main" => %{
8
+
"description" =>
9
+
"Get the service-specific admin status of a subject (account, record, or blob).",
10
+
"output" => %{
11
+
"encoding" => "application/json",
12
+
"schema" => %{
13
+
"properties" => %{
14
+
"deactivated" => %{
15
+
"ref" => "com.atproto.admin.defs#statusAttr",
16
+
"type" => "ref"
17
+
},
18
+
"subject" => %{
19
+
"refs" => [
20
+
"com.atproto.admin.defs#repoRef",
21
+
"com.atproto.repo.strongRef",
22
+
"com.atproto.admin.defs#repoBlobRef"
23
+
],
24
+
"type" => "union"
25
+
},
26
+
"takedown" => %{
27
+
"ref" => "com.atproto.admin.defs#statusAttr",
28
+
"type" => "ref"
29
+
}
30
+
},
31
+
"required" => ["subject"],
32
+
"type" => "object"
33
+
}
34
+
},
35
+
"parameters" => %{
36
+
"properties" => %{
37
+
"blob" => %{"format" => "cid", "type" => "string"},
38
+
"did" => %{"format" => "did", "type" => "string"},
39
+
"uri" => %{"format" => "at-uri", "type" => "string"}
40
+
},
41
+
"type" => "params"
42
+
},
43
+
"type" => "query"
44
+
}
45
+
},
46
+
"id" => "com.atproto.admin.getSubjectStatus",
47
+
"lexicon" => 1
48
+
})
49
+
end
+45
lib/atproto/com/atproto/admin/searchAccounts.ex
+45
lib/atproto/com/atproto/admin/searchAccounts.ex
···
1
+
defmodule Com.Atproto.Admin.SearchAccounts do
2
+
@moduledoc false
3
+
use Atex.Lexicon
4
+
5
+
deflexicon(%{
6
+
"defs" => %{
7
+
"main" => %{
8
+
"description" => "Get list of accounts that matches your search query.",
9
+
"output" => %{
10
+
"encoding" => "application/json",
11
+
"schema" => %{
12
+
"properties" => %{
13
+
"accounts" => %{
14
+
"items" => %{
15
+
"ref" => "com.atproto.admin.defs#accountView",
16
+
"type" => "ref"
17
+
},
18
+
"type" => "array"
19
+
},
20
+
"cursor" => %{"type" => "string"}
21
+
},
22
+
"required" => ["accounts"],
23
+
"type" => "object"
24
+
}
25
+
},
26
+
"parameters" => %{
27
+
"properties" => %{
28
+
"cursor" => %{"type" => "string"},
29
+
"email" => %{"type" => "string"},
30
+
"limit" => %{
31
+
"default" => 50,
32
+
"maximum" => 100,
33
+
"minimum" => 1,
34
+
"type" => "integer"
35
+
}
36
+
},
37
+
"type" => "params"
38
+
},
39
+
"type" => "query"
40
+
}
41
+
},
42
+
"id" => "com.atproto.admin.searchAccounts",
43
+
"lexicon" => 1
44
+
})
45
+
end
+41
lib/atproto/com/atproto/admin/sendEmail.ex
+41
lib/atproto/com/atproto/admin/sendEmail.ex
···
1
+
defmodule Com.Atproto.Admin.SendEmail do
2
+
@moduledoc false
3
+
use Atex.Lexicon
4
+
5
+
deflexicon(%{
6
+
"defs" => %{
7
+
"main" => %{
8
+
"description" => "Send email to a user's account email address.",
9
+
"input" => %{
10
+
"encoding" => "application/json",
11
+
"schema" => %{
12
+
"properties" => %{
13
+
"comment" => %{
14
+
"description" =>
15
+
"Additional comment by the sender that won't be used in the email itself but helpful to provide more context for moderators/reviewers",
16
+
"type" => "string"
17
+
},
18
+
"content" => %{"type" => "string"},
19
+
"recipientDid" => %{"format" => "did", "type" => "string"},
20
+
"senderDid" => %{"format" => "did", "type" => "string"},
21
+
"subject" => %{"type" => "string"}
22
+
},
23
+
"required" => ["recipientDid", "content", "senderDid"],
24
+
"type" => "object"
25
+
}
26
+
},
27
+
"output" => %{
28
+
"encoding" => "application/json",
29
+
"schema" => %{
30
+
"properties" => %{"sent" => %{"type" => "boolean"}},
31
+
"required" => ["sent"],
32
+
"type" => "object"
33
+
}
34
+
},
35
+
"type" => "procedure"
36
+
}
37
+
},
38
+
"id" => "com.atproto.admin.sendEmail",
39
+
"lexicon" => 1
40
+
})
41
+
end
+30
lib/atproto/com/atproto/admin/updateAccountEmail.ex
+30
lib/atproto/com/atproto/admin/updateAccountEmail.ex
···
1
+
defmodule Com.Atproto.Admin.UpdateAccountEmail do
2
+
@moduledoc false
3
+
use Atex.Lexicon
4
+
5
+
deflexicon(%{
6
+
"defs" => %{
7
+
"main" => %{
8
+
"description" => "Administrative action to update an account's email.",
9
+
"input" => %{
10
+
"encoding" => "application/json",
11
+
"schema" => %{
12
+
"properties" => %{
13
+
"account" => %{
14
+
"description" => "The handle or DID of the repo.",
15
+
"format" => "at-identifier",
16
+
"type" => "string"
17
+
},
18
+
"email" => %{"type" => "string"}
19
+
},
20
+
"required" => ["account", "email"],
21
+
"type" => "object"
22
+
}
23
+
},
24
+
"type" => "procedure"
25
+
}
26
+
},
27
+
"id" => "com.atproto.admin.updateAccountEmail",
28
+
"lexicon" => 1
29
+
})
30
+
end
+26
lib/atproto/com/atproto/admin/updateAccountHandle.ex
+26
lib/atproto/com/atproto/admin/updateAccountHandle.ex
···
1
+
defmodule Com.Atproto.Admin.UpdateAccountHandle do
2
+
@moduledoc false
3
+
use Atex.Lexicon
4
+
5
+
deflexicon(%{
6
+
"defs" => %{
7
+
"main" => %{
8
+
"description" => "Administrative action to update an account's handle.",
9
+
"input" => %{
10
+
"encoding" => "application/json",
11
+
"schema" => %{
12
+
"properties" => %{
13
+
"did" => %{"format" => "did", "type" => "string"},
14
+
"handle" => %{"format" => "handle", "type" => "string"}
15
+
},
16
+
"required" => ["did", "handle"],
17
+
"type" => "object"
18
+
}
19
+
},
20
+
"type" => "procedure"
21
+
}
22
+
},
23
+
"id" => "com.atproto.admin.updateAccountHandle",
24
+
"lexicon" => 1
25
+
})
26
+
end
+26
lib/atproto/com/atproto/admin/updateAccountPassword.ex
+26
lib/atproto/com/atproto/admin/updateAccountPassword.ex
···
1
+
defmodule Com.Atproto.Admin.UpdateAccountPassword do
2
+
@moduledoc false
3
+
use Atex.Lexicon
4
+
5
+
deflexicon(%{
6
+
"defs" => %{
7
+
"main" => %{
8
+
"description" => "Update the password for a user account as an administrator.",
9
+
"input" => %{
10
+
"encoding" => "application/json",
11
+
"schema" => %{
12
+
"properties" => %{
13
+
"did" => %{"format" => "did", "type" => "string"},
14
+
"password" => %{"type" => "string"}
15
+
},
16
+
"required" => ["did", "password"],
17
+
"type" => "object"
18
+
}
19
+
},
20
+
"type" => "procedure"
21
+
}
22
+
},
23
+
"id" => "com.atproto.admin.updateAccountPassword",
24
+
"lexicon" => 1
25
+
})
26
+
end
+31
lib/atproto/com/atproto/admin/updateAccountSigningKey.ex
+31
lib/atproto/com/atproto/admin/updateAccountSigningKey.ex
···
1
+
defmodule Com.Atproto.Admin.UpdateAccountSigningKey do
2
+
@moduledoc false
3
+
use Atex.Lexicon
4
+
5
+
deflexicon(%{
6
+
"defs" => %{
7
+
"main" => %{
8
+
"description" =>
9
+
"Administrative action to update an account's signing key in their Did document.",
10
+
"input" => %{
11
+
"encoding" => "application/json",
12
+
"schema" => %{
13
+
"properties" => %{
14
+
"did" => %{"format" => "did", "type" => "string"},
15
+
"signingKey" => %{
16
+
"description" => "Did-key formatted public key",
17
+
"format" => "did",
18
+
"type" => "string"
19
+
}
20
+
},
21
+
"required" => ["did", "signingKey"],
22
+
"type" => "object"
23
+
}
24
+
},
25
+
"type" => "procedure"
26
+
}
27
+
},
28
+
"id" => "com.atproto.admin.updateAccountSigningKey",
29
+
"lexicon" => 1
30
+
})
31
+
end
+62
lib/atproto/com/atproto/admin/updateSubjectStatus.ex
+62
lib/atproto/com/atproto/admin/updateSubjectStatus.ex
···
1
+
defmodule Com.Atproto.Admin.UpdateSubjectStatus do
2
+
@moduledoc false
3
+
use Atex.Lexicon
4
+
5
+
deflexicon(%{
6
+
"defs" => %{
7
+
"main" => %{
8
+
"description" =>
9
+
"Update the service-specific admin status of a subject (account, record, or blob).",
10
+
"input" => %{
11
+
"encoding" => "application/json",
12
+
"schema" => %{
13
+
"properties" => %{
14
+
"deactivated" => %{
15
+
"ref" => "com.atproto.admin.defs#statusAttr",
16
+
"type" => "ref"
17
+
},
18
+
"subject" => %{
19
+
"refs" => [
20
+
"com.atproto.admin.defs#repoRef",
21
+
"com.atproto.repo.strongRef",
22
+
"com.atproto.admin.defs#repoBlobRef"
23
+
],
24
+
"type" => "union"
25
+
},
26
+
"takedown" => %{
27
+
"ref" => "com.atproto.admin.defs#statusAttr",
28
+
"type" => "ref"
29
+
}
30
+
},
31
+
"required" => ["subject"],
32
+
"type" => "object"
33
+
}
34
+
},
35
+
"output" => %{
36
+
"encoding" => "application/json",
37
+
"schema" => %{
38
+
"properties" => %{
39
+
"subject" => %{
40
+
"refs" => [
41
+
"com.atproto.admin.defs#repoRef",
42
+
"com.atproto.repo.strongRef",
43
+
"com.atproto.admin.defs#repoBlobRef"
44
+
],
45
+
"type" => "union"
46
+
},
47
+
"takedown" => %{
48
+
"ref" => "com.atproto.admin.defs#statusAttr",
49
+
"type" => "ref"
50
+
}
51
+
},
52
+
"required" => ["subject"],
53
+
"type" => "object"
54
+
}
55
+
},
56
+
"type" => "procedure"
57
+
}
58
+
},
59
+
"id" => "com.atproto.admin.updateSubjectStatus",
60
+
"lexicon" => 1
61
+
})
62
+
end
+28
lib/atproto/com/atproto/identity/defs.ex
+28
lib/atproto/com/atproto/identity/defs.ex
···
1
+
defmodule Com.Atproto.Identity.Defs do
2
+
@moduledoc false
3
+
use Atex.Lexicon
4
+
5
+
deflexicon(%{
6
+
"defs" => %{
7
+
"identityInfo" => %{
8
+
"properties" => %{
9
+
"did" => %{"format" => "did", "type" => "string"},
10
+
"didDoc" => %{
11
+
"description" => "The complete DID document for the identity.",
12
+
"type" => "unknown"
13
+
},
14
+
"handle" => %{
15
+
"description" =>
16
+
"The validated handle of the account; or 'handle.invalid' if the handle did not bi-directionally match the DID document.",
17
+
"format" => "handle",
18
+
"type" => "string"
19
+
}
20
+
},
21
+
"required" => ["did", "handle", "didDoc"],
22
+
"type" => "object"
23
+
}
24
+
},
25
+
"id" => "com.atproto.identity.defs",
26
+
"lexicon" => 1
27
+
})
28
+
end
+36
lib/atproto/com/atproto/identity/getRecommendedDidCredentials.ex
+36
lib/atproto/com/atproto/identity/getRecommendedDidCredentials.ex
···
1
+
defmodule Com.Atproto.Identity.GetRecommendedDidCredentials do
2
+
@moduledoc false
3
+
use Atex.Lexicon
4
+
5
+
deflexicon(%{
6
+
"defs" => %{
7
+
"main" => %{
8
+
"description" =>
9
+
"Describe the credentials that should be included in the DID doc of an account that is migrating to this service.",
10
+
"output" => %{
11
+
"encoding" => "application/json",
12
+
"schema" => %{
13
+
"properties" => %{
14
+
"alsoKnownAs" => %{
15
+
"items" => %{"type" => "string"},
16
+
"type" => "array"
17
+
},
18
+
"rotationKeys" => %{
19
+
"description" =>
20
+
"Recommended rotation keys for PLC dids. Should be undefined (or ignored) for did:webs.",
21
+
"items" => %{"type" => "string"},
22
+
"type" => "array"
23
+
},
24
+
"services" => %{"type" => "unknown"},
25
+
"verificationMethods" => %{"type" => "unknown"}
26
+
},
27
+
"type" => "object"
28
+
}
29
+
},
30
+
"type" => "query"
31
+
}
32
+
},
33
+
"id" => "com.atproto.identity.getRecommendedDidCredentials",
34
+
"lexicon" => 1
35
+
})
36
+
end
+48
lib/atproto/com/atproto/identity/refreshIdentity.ex
+48
lib/atproto/com/atproto/identity/refreshIdentity.ex
···
1
+
defmodule Com.Atproto.Identity.RefreshIdentity do
2
+
@moduledoc false
3
+
use Atex.Lexicon
4
+
5
+
deflexicon(%{
6
+
"defs" => %{
7
+
"main" => %{
8
+
"description" =>
9
+
"Request that the server re-resolve an identity (DID and handle). The server may ignore this request, or require authentication, depending on the role, implementation, and policy of the server.",
10
+
"errors" => [
11
+
%{
12
+
"description" =>
13
+
"The resolution process confirmed that the handle does not resolve to any DID.",
14
+
"name" => "HandleNotFound"
15
+
},
16
+
%{
17
+
"description" => "The DID resolution process confirmed that there is no current DID.",
18
+
"name" => "DidNotFound"
19
+
},
20
+
%{
21
+
"description" => "The DID previously existed, but has been deactivated.",
22
+
"name" => "DidDeactivated"
23
+
}
24
+
],
25
+
"input" => %{
26
+
"encoding" => "application/json",
27
+
"schema" => %{
28
+
"properties" => %{
29
+
"identifier" => %{"format" => "at-identifier", "type" => "string"}
30
+
},
31
+
"required" => ["identifier"],
32
+
"type" => "object"
33
+
}
34
+
},
35
+
"output" => %{
36
+
"encoding" => "application/json",
37
+
"schema" => %{
38
+
"ref" => "com.atproto.identity.defs#identityInfo",
39
+
"type" => "ref"
40
+
}
41
+
},
42
+
"type" => "procedure"
43
+
}
44
+
},
45
+
"id" => "com.atproto.identity.refreshIdentity",
46
+
"lexicon" => 1
47
+
})
48
+
end
+16
lib/atproto/com/atproto/identity/requestPlcOperationSignature.ex
+16
lib/atproto/com/atproto/identity/requestPlcOperationSignature.ex
···
1
+
defmodule Com.Atproto.Identity.RequestPlcOperationSignature do
2
+
@moduledoc false
3
+
use Atex.Lexicon
4
+
5
+
deflexicon(%{
6
+
"defs" => %{
7
+
"main" => %{
8
+
"description" =>
9
+
"Request an email with a code to in order to request a signed PLC operation. Requires Auth.",
10
+
"type" => "procedure"
11
+
}
12
+
},
13
+
"id" => "com.atproto.identity.requestPlcOperationSignature",
14
+
"lexicon" => 1
15
+
})
16
+
end
+49
lib/atproto/com/atproto/identity/resolveDid.ex
+49
lib/atproto/com/atproto/identity/resolveDid.ex
···
1
+
defmodule Com.Atproto.Identity.ResolveDid do
2
+
@moduledoc false
3
+
use Atex.Lexicon
4
+
5
+
deflexicon(%{
6
+
"defs" => %{
7
+
"main" => %{
8
+
"description" => "Resolves DID to DID document. Does not bi-directionally verify handle.",
9
+
"errors" => [
10
+
%{
11
+
"description" => "The DID resolution process confirmed that there is no current DID.",
12
+
"name" => "DidNotFound"
13
+
},
14
+
%{
15
+
"description" => "The DID previously existed, but has been deactivated.",
16
+
"name" => "DidDeactivated"
17
+
}
18
+
],
19
+
"output" => %{
20
+
"encoding" => "application/json",
21
+
"schema" => %{
22
+
"properties" => %{
23
+
"didDoc" => %{
24
+
"description" => "The complete DID document for the identity.",
25
+
"type" => "unknown"
26
+
}
27
+
},
28
+
"required" => ["didDoc"],
29
+
"type" => "object"
30
+
}
31
+
},
32
+
"parameters" => %{
33
+
"properties" => %{
34
+
"did" => %{
35
+
"description" => "DID to resolve.",
36
+
"format" => "did",
37
+
"type" => "string"
38
+
}
39
+
},
40
+
"required" => ["did"],
41
+
"type" => "params"
42
+
},
43
+
"type" => "query"
44
+
}
45
+
},
46
+
"id" => "com.atproto.identity.resolveDid",
47
+
"lexicon" => 1
48
+
})
49
+
end
+42
lib/atproto/com/atproto/identity/resolveHandle.ex
+42
lib/atproto/com/atproto/identity/resolveHandle.ex
···
1
+
defmodule Com.Atproto.Identity.ResolveHandle do
2
+
@moduledoc false
3
+
use Atex.Lexicon
4
+
5
+
deflexicon(%{
6
+
"defs" => %{
7
+
"main" => %{
8
+
"description" =>
9
+
"Resolves an atproto handle (hostname) to a DID. Does not necessarily bi-directionally verify against the the DID document.",
10
+
"errors" => [
11
+
%{
12
+
"description" =>
13
+
"The resolution process confirmed that the handle does not resolve to any DID.",
14
+
"name" => "HandleNotFound"
15
+
}
16
+
],
17
+
"output" => %{
18
+
"encoding" => "application/json",
19
+
"schema" => %{
20
+
"properties" => %{"did" => %{"format" => "did", "type" => "string"}},
21
+
"required" => ["did"],
22
+
"type" => "object"
23
+
}
24
+
},
25
+
"parameters" => %{
26
+
"properties" => %{
27
+
"handle" => %{
28
+
"description" => "The handle to resolve.",
29
+
"format" => "handle",
30
+
"type" => "string"
31
+
}
32
+
},
33
+
"required" => ["handle"],
34
+
"type" => "params"
35
+
},
36
+
"type" => "query"
37
+
}
38
+
},
39
+
"id" => "com.atproto.identity.resolveHandle",
40
+
"lexicon" => 1
41
+
})
42
+
end
+49
lib/atproto/com/atproto/identity/resolveIdentity.ex
+49
lib/atproto/com/atproto/identity/resolveIdentity.ex
···
1
+
defmodule Com.Atproto.Identity.ResolveIdentity do
2
+
@moduledoc false
3
+
use Atex.Lexicon
4
+
5
+
deflexicon(%{
6
+
"defs" => %{
7
+
"main" => %{
8
+
"description" =>
9
+
"Resolves an identity (DID or Handle) to a full identity (DID document and verified handle).",
10
+
"errors" => [
11
+
%{
12
+
"description" =>
13
+
"The resolution process confirmed that the handle does not resolve to any DID.",
14
+
"name" => "HandleNotFound"
15
+
},
16
+
%{
17
+
"description" => "The DID resolution process confirmed that there is no current DID.",
18
+
"name" => "DidNotFound"
19
+
},
20
+
%{
21
+
"description" => "The DID previously existed, but has been deactivated.",
22
+
"name" => "DidDeactivated"
23
+
}
24
+
],
25
+
"output" => %{
26
+
"encoding" => "application/json",
27
+
"schema" => %{
28
+
"ref" => "com.atproto.identity.defs#identityInfo",
29
+
"type" => "ref"
30
+
}
31
+
},
32
+
"parameters" => %{
33
+
"properties" => %{
34
+
"identifier" => %{
35
+
"description" => "Handle or DID to resolve.",
36
+
"format" => "at-identifier",
37
+
"type" => "string"
38
+
}
39
+
},
40
+
"required" => ["identifier"],
41
+
"type" => "params"
42
+
},
43
+
"type" => "query"
44
+
}
45
+
},
46
+
"id" => "com.atproto.identity.resolveIdentity",
47
+
"lexicon" => 1
48
+
})
49
+
end
+52
lib/atproto/com/atproto/identity/signPlcOperation.ex
+52
lib/atproto/com/atproto/identity/signPlcOperation.ex
···
1
+
defmodule Com.Atproto.Identity.SignPlcOperation do
2
+
@moduledoc false
3
+
use Atex.Lexicon
4
+
5
+
deflexicon(%{
6
+
"defs" => %{
7
+
"main" => %{
8
+
"description" =>
9
+
"Signs a PLC operation to update some value(s) in the requesting DID's document.",
10
+
"input" => %{
11
+
"encoding" => "application/json",
12
+
"schema" => %{
13
+
"properties" => %{
14
+
"alsoKnownAs" => %{
15
+
"items" => %{"type" => "string"},
16
+
"type" => "array"
17
+
},
18
+
"rotationKeys" => %{
19
+
"items" => %{"type" => "string"},
20
+
"type" => "array"
21
+
},
22
+
"services" => %{"type" => "unknown"},
23
+
"token" => %{
24
+
"description" =>
25
+
"A token received through com.atproto.identity.requestPlcOperationSignature",
26
+
"type" => "string"
27
+
},
28
+
"verificationMethods" => %{"type" => "unknown"}
29
+
},
30
+
"type" => "object"
31
+
}
32
+
},
33
+
"output" => %{
34
+
"encoding" => "application/json",
35
+
"schema" => %{
36
+
"properties" => %{
37
+
"operation" => %{
38
+
"description" => "A signed DID PLC operation.",
39
+
"type" => "unknown"
40
+
}
41
+
},
42
+
"required" => ["operation"],
43
+
"type" => "object"
44
+
}
45
+
},
46
+
"type" => "procedure"
47
+
}
48
+
},
49
+
"id" => "com.atproto.identity.signPlcOperation",
50
+
"lexicon" => 1
51
+
})
52
+
end
+24
lib/atproto/com/atproto/identity/submitPlcOperation.ex
+24
lib/atproto/com/atproto/identity/submitPlcOperation.ex
···
1
+
defmodule Com.Atproto.Identity.SubmitPlcOperation do
2
+
@moduledoc false
3
+
use Atex.Lexicon
4
+
5
+
deflexicon(%{
6
+
"defs" => %{
7
+
"main" => %{
8
+
"description" =>
9
+
"Validates a PLC operation to ensure that it doesn't violate a service's constraints or get the identity into a bad state, then submits it to the PLC registry",
10
+
"input" => %{
11
+
"encoding" => "application/json",
12
+
"schema" => %{
13
+
"properties" => %{"operation" => %{"type" => "unknown"}},
14
+
"required" => ["operation"],
15
+
"type" => "object"
16
+
}
17
+
},
18
+
"type" => "procedure"
19
+
}
20
+
},
21
+
"id" => "com.atproto.identity.submitPlcOperation",
22
+
"lexicon" => 1
23
+
})
24
+
end
+30
lib/atproto/com/atproto/identity/updateHandle.ex
+30
lib/atproto/com/atproto/identity/updateHandle.ex
···
1
+
defmodule Com.Atproto.Identity.UpdateHandle do
2
+
@moduledoc false
3
+
use Atex.Lexicon
4
+
5
+
deflexicon(%{
6
+
"defs" => %{
7
+
"main" => %{
8
+
"description" =>
9
+
"Updates the current account's handle. Verifies handle validity, and updates did:plc document if necessary. Implemented by PDS, and requires auth.",
10
+
"input" => %{
11
+
"encoding" => "application/json",
12
+
"schema" => %{
13
+
"properties" => %{
14
+
"handle" => %{
15
+
"description" => "The new handle.",
16
+
"format" => "handle",
17
+
"type" => "string"
18
+
}
19
+
},
20
+
"required" => ["handle"],
21
+
"type" => "object"
22
+
}
23
+
},
24
+
"type" => "procedure"
25
+
}
26
+
},
27
+
"id" => "com.atproto.identity.updateHandle",
28
+
"lexicon" => 1
29
+
})
30
+
end
+171
lib/atproto/com/atproto/label/defs.ex
+171
lib/atproto/com/atproto/label/defs.ex
···
1
+
defmodule Com.Atproto.Label.Defs do
2
+
@moduledoc false
3
+
use Atex.Lexicon
4
+
5
+
deflexicon(%{
6
+
"defs" => %{
7
+
"label" => %{
8
+
"description" => "Metadata tag on an atproto resource (eg, repo or record).",
9
+
"properties" => %{
10
+
"cid" => %{
11
+
"description" =>
12
+
"Optionally, CID specifying the specific version of 'uri' resource this label applies to.",
13
+
"format" => "cid",
14
+
"type" => "string"
15
+
},
16
+
"cts" => %{
17
+
"description" => "Timestamp when this label was created.",
18
+
"format" => "datetime",
19
+
"type" => "string"
20
+
},
21
+
"exp" => %{
22
+
"description" => "Timestamp at which this label expires (no longer applies).",
23
+
"format" => "datetime",
24
+
"type" => "string"
25
+
},
26
+
"neg" => %{
27
+
"description" => "If true, this is a negation label, overwriting a previous label.",
28
+
"type" => "boolean"
29
+
},
30
+
"sig" => %{
31
+
"description" => "Signature of dag-cbor encoded label.",
32
+
"type" => "bytes"
33
+
},
34
+
"src" => %{
35
+
"description" => "DID of the actor who created this label.",
36
+
"format" => "did",
37
+
"type" => "string"
38
+
},
39
+
"uri" => %{
40
+
"description" =>
41
+
"AT URI of the record, repository (account), or other resource that this label applies to.",
42
+
"format" => "uri",
43
+
"type" => "string"
44
+
},
45
+
"val" => %{
46
+
"description" => "The short string name of the value or type of this label.",
47
+
"maxLength" => 128,
48
+
"type" => "string"
49
+
},
50
+
"ver" => %{
51
+
"description" => "The AT Protocol version of the label object.",
52
+
"type" => "integer"
53
+
}
54
+
},
55
+
"required" => ["src", "uri", "val", "cts"],
56
+
"type" => "object"
57
+
},
58
+
"labelValue" => %{
59
+
"knownValues" => [
60
+
"!hide",
61
+
"!no-promote",
62
+
"!warn",
63
+
"!no-unauthenticated",
64
+
"dmca-violation",
65
+
"doxxing",
66
+
"porn",
67
+
"sexual",
68
+
"nudity",
69
+
"nsfl",
70
+
"gore"
71
+
],
72
+
"type" => "string"
73
+
},
74
+
"labelValueDefinition" => %{
75
+
"description" => "Declares a label value and its expected interpretations and behaviors.",
76
+
"properties" => %{
77
+
"adultOnly" => %{
78
+
"description" =>
79
+
"Does the user need to have adult content enabled in order to configure this label?",
80
+
"type" => "boolean"
81
+
},
82
+
"blurs" => %{
83
+
"description" =>
84
+
"What should this label hide in the UI, if applied? 'content' hides all of the target; 'media' hides the images/video/audio; 'none' hides nothing.",
85
+
"knownValues" => ["content", "media", "none"],
86
+
"type" => "string"
87
+
},
88
+
"defaultSetting" => %{
89
+
"default" => "warn",
90
+
"description" => "The default setting for this label.",
91
+
"knownValues" => ["ignore", "warn", "hide"],
92
+
"type" => "string"
93
+
},
94
+
"identifier" => %{
95
+
"description" =>
96
+
"The value of the label being defined. Must only include lowercase ascii and the '-' character ([a-z-]+).",
97
+
"maxGraphemes" => 100,
98
+
"maxLength" => 100,
99
+
"type" => "string"
100
+
},
101
+
"locales" => %{
102
+
"items" => %{"ref" => "#labelValueDefinitionStrings", "type" => "ref"},
103
+
"type" => "array"
104
+
},
105
+
"severity" => %{
106
+
"description" =>
107
+
"How should a client visually convey this label? 'inform' means neutral and informational; 'alert' means negative and warning; 'none' means show nothing.",
108
+
"knownValues" => ["inform", "alert", "none"],
109
+
"type" => "string"
110
+
}
111
+
},
112
+
"required" => ["identifier", "severity", "blurs", "locales"],
113
+
"type" => "object"
114
+
},
115
+
"labelValueDefinitionStrings" => %{
116
+
"description" =>
117
+
"Strings which describe the label in the UI, localized into a specific language.",
118
+
"properties" => %{
119
+
"description" => %{
120
+
"description" =>
121
+
"A longer description of what the label means and why it might be applied.",
122
+
"maxGraphemes" => 10000,
123
+
"maxLength" => 100_000,
124
+
"type" => "string"
125
+
},
126
+
"lang" => %{
127
+
"description" => "The code of the language these strings are written in.",
128
+
"format" => "language",
129
+
"type" => "string"
130
+
},
131
+
"name" => %{
132
+
"description" => "A short human-readable name for the label.",
133
+
"maxGraphemes" => 64,
134
+
"maxLength" => 640,
135
+
"type" => "string"
136
+
}
137
+
},
138
+
"required" => ["lang", "name", "description"],
139
+
"type" => "object"
140
+
},
141
+
"selfLabel" => %{
142
+
"description" =>
143
+
"Metadata tag on an atproto record, published by the author within the record. Note that schemas should use #selfLabels, not #selfLabel.",
144
+
"properties" => %{
145
+
"val" => %{
146
+
"description" => "The short string name of the value or type of this label.",
147
+
"maxLength" => 128,
148
+
"type" => "string"
149
+
}
150
+
},
151
+
"required" => ["val"],
152
+
"type" => "object"
153
+
},
154
+
"selfLabels" => %{
155
+
"description" =>
156
+
"Metadata tags on an atproto record, published by the author within the record.",
157
+
"properties" => %{
158
+
"values" => %{
159
+
"items" => %{"ref" => "#selfLabel", "type" => "ref"},
160
+
"maxLength" => 10,
161
+
"type" => "array"
162
+
}
163
+
},
164
+
"required" => ["values"],
165
+
"type" => "object"
166
+
}
167
+
},
168
+
"id" => "com.atproto.label.defs",
169
+
"lexicon" => 1
170
+
})
171
+
end
+57
lib/atproto/com/atproto/label/queryLabels.ex
+57
lib/atproto/com/atproto/label/queryLabels.ex
···
1
+
defmodule Com.Atproto.Label.QueryLabels do
2
+
@moduledoc false
3
+
use Atex.Lexicon
4
+
5
+
deflexicon(%{
6
+
"defs" => %{
7
+
"main" => %{
8
+
"description" =>
9
+
"Find labels relevant to the provided AT-URI patterns. Public endpoint for moderation services, though may return different or additional results with auth.",
10
+
"output" => %{
11
+
"encoding" => "application/json",
12
+
"schema" => %{
13
+
"properties" => %{
14
+
"cursor" => %{"type" => "string"},
15
+
"labels" => %{
16
+
"items" => %{
17
+
"ref" => "com.atproto.label.defs#label",
18
+
"type" => "ref"
19
+
},
20
+
"type" => "array"
21
+
}
22
+
},
23
+
"required" => ["labels"],
24
+
"type" => "object"
25
+
}
26
+
},
27
+
"parameters" => %{
28
+
"properties" => %{
29
+
"cursor" => %{"type" => "string"},
30
+
"limit" => %{
31
+
"default" => 50,
32
+
"maximum" => 250,
33
+
"minimum" => 1,
34
+
"type" => "integer"
35
+
},
36
+
"sources" => %{
37
+
"description" => "Optional list of label sources (DIDs) to filter on.",
38
+
"items" => %{"format" => "did", "type" => "string"},
39
+
"type" => "array"
40
+
},
41
+
"uriPatterns" => %{
42
+
"description" =>
43
+
"List of AT URI patterns to match (boolean 'OR'). Each may be a prefix (ending with '*'; will match inclusive of the string leading to '*'), or a full URI.",
44
+
"items" => %{"type" => "string"},
45
+
"type" => "array"
46
+
}
47
+
},
48
+
"required" => ["uriPatterns"],
49
+
"type" => "params"
50
+
},
51
+
"type" => "query"
52
+
}
53
+
},
54
+
"id" => "com.atproto.label.queryLabels",
55
+
"lexicon" => 1
56
+
})
57
+
end
+48
lib/atproto/com/atproto/label/subscribeLabels.ex
+48
lib/atproto/com/atproto/label/subscribeLabels.ex
···
1
+
defmodule Com.Atproto.Label.SubscribeLabels do
2
+
@moduledoc false
3
+
use Atex.Lexicon
4
+
5
+
deflexicon(%{
6
+
"defs" => %{
7
+
"info" => %{
8
+
"properties" => %{
9
+
"message" => %{"type" => "string"},
10
+
"name" => %{"knownValues" => ["OutdatedCursor"], "type" => "string"}
11
+
},
12
+
"required" => ["name"],
13
+
"type" => "object"
14
+
},
15
+
"labels" => %{
16
+
"properties" => %{
17
+
"labels" => %{
18
+
"items" => %{"ref" => "com.atproto.label.defs#label", "type" => "ref"},
19
+
"type" => "array"
20
+
},
21
+
"seq" => %{"type" => "integer"}
22
+
},
23
+
"required" => ["seq", "labels"],
24
+
"type" => "object"
25
+
},
26
+
"main" => %{
27
+
"description" =>
28
+
"Subscribe to stream of labels (and negations). Public endpoint implemented by mod services. Uses same sequencing scheme as repo event stream.",
29
+
"errors" => [%{"name" => "FutureCursor"}],
30
+
"message" => %{
31
+
"schema" => %{"refs" => ["#labels", "#info"], "type" => "union"}
32
+
},
33
+
"parameters" => %{
34
+
"properties" => %{
35
+
"cursor" => %{
36
+
"description" => "The last known event seq number to backfill from.",
37
+
"type" => "integer"
38
+
}
39
+
},
40
+
"type" => "params"
41
+
},
42
+
"type" => "subscription"
43
+
}
44
+
},
45
+
"id" => "com.atproto.label.subscribeLabels",
46
+
"lexicon" => 1
47
+
})
48
+
end
+28
lib/atproto/com/atproto/lexicon/schema.ex
+28
lib/atproto/com/atproto/lexicon/schema.ex
···
1
+
defmodule Com.Atproto.Lexicon.Schema do
2
+
@moduledoc false
3
+
use Atex.Lexicon
4
+
5
+
deflexicon(%{
6
+
"defs" => %{
7
+
"main" => %{
8
+
"description" =>
9
+
"Representation of Lexicon schemas themselves, when published as atproto records. Note that the schema language is not defined in Lexicon; this meta schema currently only includes a single version field ('lexicon'). See the atproto specifications for description of the other expected top-level fields ('id', 'defs', etc).",
10
+
"key" => "nsid",
11
+
"record" => %{
12
+
"properties" => %{
13
+
"lexicon" => %{
14
+
"description" =>
15
+
"Indicates the 'version' of the Lexicon language. Must be '1' for the current atproto/Lexicon schema system.",
16
+
"type" => "integer"
17
+
}
18
+
},
19
+
"required" => ["lexicon"],
20
+
"type" => "object"
21
+
},
22
+
"type" => "record"
23
+
}
24
+
},
25
+
"id" => "com.atproto.lexicon.schema",
26
+
"lexicon" => 1
27
+
})
28
+
end
+82
lib/atproto/com/atproto/moderation/createReport.ex
+82
lib/atproto/com/atproto/moderation/createReport.ex
···
1
+
defmodule Com.Atproto.Moderation.CreateReport do
2
+
@moduledoc false
3
+
use Atex.Lexicon
4
+
5
+
deflexicon(%{
6
+
"defs" => %{
7
+
"main" => %{
8
+
"description" =>
9
+
"Submit a moderation report regarding an atproto account or record. Implemented by moderation services (with PDS proxying), and requires auth.",
10
+
"input" => %{
11
+
"encoding" => "application/json",
12
+
"schema" => %{
13
+
"properties" => %{
14
+
"modTool" => %{"ref" => "#modTool", "type" => "ref"},
15
+
"reason" => %{
16
+
"description" => "Additional context about the content and violation.",
17
+
"maxGraphemes" => 2000,
18
+
"maxLength" => 20000,
19
+
"type" => "string"
20
+
},
21
+
"reasonType" => %{
22
+
"description" => "Indicates the broad category of violation the report is for.",
23
+
"ref" => "com.atproto.moderation.defs#reasonType",
24
+
"type" => "ref"
25
+
},
26
+
"subject" => %{
27
+
"refs" => ["com.atproto.admin.defs#repoRef", "com.atproto.repo.strongRef"],
28
+
"type" => "union"
29
+
}
30
+
},
31
+
"required" => ["reasonType", "subject"],
32
+
"type" => "object"
33
+
}
34
+
},
35
+
"output" => %{
36
+
"encoding" => "application/json",
37
+
"schema" => %{
38
+
"properties" => %{
39
+
"createdAt" => %{"format" => "datetime", "type" => "string"},
40
+
"id" => %{"type" => "integer"},
41
+
"reason" => %{
42
+
"maxGraphemes" => 2000,
43
+
"maxLength" => 20000,
44
+
"type" => "string"
45
+
},
46
+
"reasonType" => %{
47
+
"ref" => "com.atproto.moderation.defs#reasonType",
48
+
"type" => "ref"
49
+
},
50
+
"reportedBy" => %{"format" => "did", "type" => "string"},
51
+
"subject" => %{
52
+
"refs" => ["com.atproto.admin.defs#repoRef", "com.atproto.repo.strongRef"],
53
+
"type" => "union"
54
+
}
55
+
},
56
+
"required" => ["id", "reasonType", "subject", "reportedBy", "createdAt"],
57
+
"type" => "object"
58
+
}
59
+
},
60
+
"type" => "procedure"
61
+
},
62
+
"modTool" => %{
63
+
"description" => "Moderation tool information for tracing the source of the action",
64
+
"properties" => %{
65
+
"meta" => %{
66
+
"description" => "Additional arbitrary metadata about the source",
67
+
"type" => "unknown"
68
+
},
69
+
"name" => %{
70
+
"description" =>
71
+
"Name/identifier of the source (e.g., 'bsky-app/android', 'bsky-web/chrome')",
72
+
"type" => "string"
73
+
}
74
+
},
75
+
"required" => ["name"],
76
+
"type" => "object"
77
+
}
78
+
},
79
+
"id" => "com.atproto.moderation.createReport",
80
+
"lexicon" => 1
81
+
})
82
+
end
+102
lib/atproto/com/atproto/moderation/defs.ex
+102
lib/atproto/com/atproto/moderation/defs.ex
···
1
+
defmodule Com.Atproto.Moderation.Defs do
2
+
@moduledoc false
3
+
use Atex.Lexicon
4
+
5
+
deflexicon(%{
6
+
"defs" => %{
7
+
"reasonAppeal" => %{
8
+
"description" => "Appeal a previously taken moderation action",
9
+
"type" => "token"
10
+
},
11
+
"reasonMisleading" => %{
12
+
"description" =>
13
+
"Misleading identity, affiliation, or content. Prefer new lexicon definition `tools.ozone.report.defs#reasonMisleadingOther`.",
14
+
"type" => "token"
15
+
},
16
+
"reasonOther" => %{
17
+
"description" =>
18
+
"Reports not falling under another report category. Prefer new lexicon definition `tools.ozone.report.defs#reasonOther`.",
19
+
"type" => "token"
20
+
},
21
+
"reasonRude" => %{
22
+
"description" =>
23
+
"Rude, harassing, explicit, or otherwise unwelcoming behavior. Prefer new lexicon definition `tools.ozone.report.defs#reasonHarassmentOther`.",
24
+
"type" => "token"
25
+
},
26
+
"reasonSexual" => %{
27
+
"description" =>
28
+
"Unwanted or mislabeled sexual content. Prefer new lexicon definition `tools.ozone.report.defs#reasonSexualUnlabeled`.",
29
+
"type" => "token"
30
+
},
31
+
"reasonSpam" => %{
32
+
"description" =>
33
+
"Spam: frequent unwanted promotion, replies, mentions. Prefer new lexicon definition `tools.ozone.report.defs#reasonMisleadingSpam`.",
34
+
"type" => "token"
35
+
},
36
+
"reasonType" => %{
37
+
"knownValues" => [
38
+
"com.atproto.moderation.defs#reasonSpam",
39
+
"com.atproto.moderation.defs#reasonViolation",
40
+
"com.atproto.moderation.defs#reasonMisleading",
41
+
"com.atproto.moderation.defs#reasonSexual",
42
+
"com.atproto.moderation.defs#reasonRude",
43
+
"com.atproto.moderation.defs#reasonOther",
44
+
"com.atproto.moderation.defs#reasonAppeal",
45
+
"tools.ozone.report.defs#reasonAppeal",
46
+
"tools.ozone.report.defs#reasonOther",
47
+
"tools.ozone.report.defs#reasonViolenceAnimal",
48
+
"tools.ozone.report.defs#reasonViolenceThreats",
49
+
"tools.ozone.report.defs#reasonViolenceGraphicContent",
50
+
"tools.ozone.report.defs#reasonViolenceGlorification",
51
+
"tools.ozone.report.defs#reasonViolenceExtremistContent",
52
+
"tools.ozone.report.defs#reasonViolenceTrafficking",
53
+
"tools.ozone.report.defs#reasonViolenceOther",
54
+
"tools.ozone.report.defs#reasonSexualAbuseContent",
55
+
"tools.ozone.report.defs#reasonSexualNCII",
56
+
"tools.ozone.report.defs#reasonSexualDeepfake",
57
+
"tools.ozone.report.defs#reasonSexualAnimal",
58
+
"tools.ozone.report.defs#reasonSexualUnlabeled",
59
+
"tools.ozone.report.defs#reasonSexualOther",
60
+
"tools.ozone.report.defs#reasonChildSafetyCSAM",
61
+
"tools.ozone.report.defs#reasonChildSafetyGroom",
62
+
"tools.ozone.report.defs#reasonChildSafetyPrivacy",
63
+
"tools.ozone.report.defs#reasonChildSafetyHarassment",
64
+
"tools.ozone.report.defs#reasonChildSafetyOther",
65
+
"tools.ozone.report.defs#reasonHarassmentTroll",
66
+
"tools.ozone.report.defs#reasonHarassmentTargeted",
67
+
"tools.ozone.report.defs#reasonHarassmentHateSpeech",
68
+
"tools.ozone.report.defs#reasonHarassmentDoxxing",
69
+
"tools.ozone.report.defs#reasonHarassmentOther",
70
+
"tools.ozone.report.defs#reasonMisleadingBot",
71
+
"tools.ozone.report.defs#reasonMisleadingImpersonation",
72
+
"tools.ozone.report.defs#reasonMisleadingSpam",
73
+
"tools.ozone.report.defs#reasonMisleadingScam",
74
+
"tools.ozone.report.defs#reasonMisleadingElections",
75
+
"tools.ozone.report.defs#reasonMisleadingOther",
76
+
"tools.ozone.report.defs#reasonRuleSiteSecurity",
77
+
"tools.ozone.report.defs#reasonRuleProhibitedSales",
78
+
"tools.ozone.report.defs#reasonRuleBanEvasion",
79
+
"tools.ozone.report.defs#reasonRuleOther",
80
+
"tools.ozone.report.defs#reasonSelfHarmContent",
81
+
"tools.ozone.report.defs#reasonSelfHarmED",
82
+
"tools.ozone.report.defs#reasonSelfHarmStunts",
83
+
"tools.ozone.report.defs#reasonSelfHarmSubstances",
84
+
"tools.ozone.report.defs#reasonSelfHarmOther"
85
+
],
86
+
"type" => "string"
87
+
},
88
+
"reasonViolation" => %{
89
+
"description" =>
90
+
"Direct violation of server rules, laws, terms of service. Prefer new lexicon definition `tools.ozone.report.defs#reasonRuleOther`.",
91
+
"type" => "token"
92
+
},
93
+
"subjectType" => %{
94
+
"description" => "Tag describing a type of subject that might be reported.",
95
+
"knownValues" => ["account", "record", "chat"],
96
+
"type" => "string"
97
+
}
98
+
},
99
+
"id" => "com.atproto.moderation.defs",
100
+
"lexicon" => 1
101
+
})
102
+
end
+141
lib/atproto/com/atproto/repo/applyWrites.ex
+141
lib/atproto/com/atproto/repo/applyWrites.ex
···
1
+
defmodule Com.Atproto.Repo.ApplyWrites do
2
+
@moduledoc false
3
+
use Atex.Lexicon
4
+
5
+
deflexicon(%{
6
+
"defs" => %{
7
+
"create" => %{
8
+
"description" => "Operation which creates a new record.",
9
+
"properties" => %{
10
+
"collection" => %{"format" => "nsid", "type" => "string"},
11
+
"rkey" => %{
12
+
"description" =>
13
+
"NOTE: maxLength is redundant with record-key format. Keeping it temporarily to ensure backwards compatibility.",
14
+
"format" => "record-key",
15
+
"maxLength" => 512,
16
+
"type" => "string"
17
+
},
18
+
"value" => %{"type" => "unknown"}
19
+
},
20
+
"required" => ["collection", "value"],
21
+
"type" => "object"
22
+
},
23
+
"createResult" => %{
24
+
"properties" => %{
25
+
"cid" => %{"format" => "cid", "type" => "string"},
26
+
"uri" => %{"format" => "at-uri", "type" => "string"},
27
+
"validationStatus" => %{
28
+
"knownValues" => ["valid", "unknown"],
29
+
"type" => "string"
30
+
}
31
+
},
32
+
"required" => ["uri", "cid"],
33
+
"type" => "object"
34
+
},
35
+
"delete" => %{
36
+
"description" => "Operation which deletes an existing record.",
37
+
"properties" => %{
38
+
"collection" => %{"format" => "nsid", "type" => "string"},
39
+
"rkey" => %{"format" => "record-key", "type" => "string"}
40
+
},
41
+
"required" => ["collection", "rkey"],
42
+
"type" => "object"
43
+
},
44
+
"deleteResult" => %{
45
+
"properties" => %{},
46
+
"required" => [],
47
+
"type" => "object"
48
+
},
49
+
"main" => %{
50
+
"description" =>
51
+
"Apply a batch transaction of repository creates, updates, and deletes. Requires auth, implemented by PDS.",
52
+
"errors" => [
53
+
%{
54
+
"description" =>
55
+
"Indicates that the 'swapCommit' parameter did not match current commit.",
56
+
"name" => "InvalidSwap"
57
+
}
58
+
],
59
+
"input" => %{
60
+
"encoding" => "application/json",
61
+
"schema" => %{
62
+
"properties" => %{
63
+
"repo" => %{
64
+
"description" => "The handle or DID of the repo (aka, current account).",
65
+
"format" => "at-identifier",
66
+
"type" => "string"
67
+
},
68
+
"swapCommit" => %{
69
+
"description" =>
70
+
"If provided, the entire operation will fail if the current repo commit CID does not match this value. Used to prevent conflicting repo mutations.",
71
+
"format" => "cid",
72
+
"type" => "string"
73
+
},
74
+
"validate" => %{
75
+
"description" =>
76
+
"Can be set to 'false' to skip Lexicon schema validation of record data across all operations, 'true' to require it, or leave unset to validate only for known Lexicons.",
77
+
"type" => "boolean"
78
+
},
79
+
"writes" => %{
80
+
"items" => %{
81
+
"closed" => true,
82
+
"refs" => ["#create", "#update", "#delete"],
83
+
"type" => "union"
84
+
},
85
+
"type" => "array"
86
+
}
87
+
},
88
+
"required" => ["repo", "writes"],
89
+
"type" => "object"
90
+
}
91
+
},
92
+
"output" => %{
93
+
"encoding" => "application/json",
94
+
"schema" => %{
95
+
"properties" => %{
96
+
"commit" => %{
97
+
"ref" => "com.atproto.repo.defs#commitMeta",
98
+
"type" => "ref"
99
+
},
100
+
"results" => %{
101
+
"items" => %{
102
+
"closed" => true,
103
+
"refs" => ["#createResult", "#updateResult", "#deleteResult"],
104
+
"type" => "union"
105
+
},
106
+
"type" => "array"
107
+
}
108
+
},
109
+
"required" => [],
110
+
"type" => "object"
111
+
}
112
+
},
113
+
"type" => "procedure"
114
+
},
115
+
"update" => %{
116
+
"description" => "Operation which updates an existing record.",
117
+
"properties" => %{
118
+
"collection" => %{"format" => "nsid", "type" => "string"},
119
+
"rkey" => %{"format" => "record-key", "type" => "string"},
120
+
"value" => %{"type" => "unknown"}
121
+
},
122
+
"required" => ["collection", "rkey", "value"],
123
+
"type" => "object"
124
+
},
125
+
"updateResult" => %{
126
+
"properties" => %{
127
+
"cid" => %{"format" => "cid", "type" => "string"},
128
+
"uri" => %{"format" => "at-uri", "type" => "string"},
129
+
"validationStatus" => %{
130
+
"knownValues" => ["valid", "unknown"],
131
+
"type" => "string"
132
+
}
133
+
},
134
+
"required" => ["uri", "cid"],
135
+
"type" => "object"
136
+
}
137
+
},
138
+
"id" => "com.atproto.repo.applyWrites",
139
+
"lexicon" => 1
140
+
})
141
+
end
+80
lib/atproto/com/atproto/repo/createRecord.ex
+80
lib/atproto/com/atproto/repo/createRecord.ex
···
1
+
defmodule Com.Atproto.Repo.CreateRecord do
2
+
@moduledoc false
3
+
use Atex.Lexicon
4
+
5
+
deflexicon(%{
6
+
"defs" => %{
7
+
"main" => %{
8
+
"description" =>
9
+
"Create a single new repository record. Requires auth, implemented by PDS.",
10
+
"errors" => [
11
+
%{
12
+
"description" => "Indicates that 'swapCommit' didn't match current repo commit.",
13
+
"name" => "InvalidSwap"
14
+
}
15
+
],
16
+
"input" => %{
17
+
"encoding" => "application/json",
18
+
"schema" => %{
19
+
"properties" => %{
20
+
"collection" => %{
21
+
"description" => "The NSID of the record collection.",
22
+
"format" => "nsid",
23
+
"type" => "string"
24
+
},
25
+
"record" => %{
26
+
"description" => "The record itself. Must contain a $type field.",
27
+
"type" => "unknown"
28
+
},
29
+
"repo" => %{
30
+
"description" => "The handle or DID of the repo (aka, current account).",
31
+
"format" => "at-identifier",
32
+
"type" => "string"
33
+
},
34
+
"rkey" => %{
35
+
"description" => "The Record Key.",
36
+
"format" => "record-key",
37
+
"maxLength" => 512,
38
+
"type" => "string"
39
+
},
40
+
"swapCommit" => %{
41
+
"description" => "Compare and swap with the previous commit by CID.",
42
+
"format" => "cid",
43
+
"type" => "string"
44
+
},
45
+
"validate" => %{
46
+
"description" =>
47
+
"Can be set to 'false' to skip Lexicon schema validation of record data, 'true' to require it, or leave unset to validate only for known Lexicons.",
48
+
"type" => "boolean"
49
+
}
50
+
},
51
+
"required" => ["repo", "collection", "record"],
52
+
"type" => "object"
53
+
}
54
+
},
55
+
"output" => %{
56
+
"encoding" => "application/json",
57
+
"schema" => %{
58
+
"properties" => %{
59
+
"cid" => %{"format" => "cid", "type" => "string"},
60
+
"commit" => %{
61
+
"ref" => "com.atproto.repo.defs#commitMeta",
62
+
"type" => "ref"
63
+
},
64
+
"uri" => %{"format" => "at-uri", "type" => "string"},
65
+
"validationStatus" => %{
66
+
"knownValues" => ["valid", "unknown"],
67
+
"type" => "string"
68
+
}
69
+
},
70
+
"required" => ["uri", "cid"],
71
+
"type" => "object"
72
+
}
73
+
},
74
+
"type" => "procedure"
75
+
}
76
+
},
77
+
"id" => "com.atproto.repo.createRecord",
78
+
"lexicon" => 1
79
+
})
80
+
end
+19
lib/atproto/com/atproto/repo/defs.ex
+19
lib/atproto/com/atproto/repo/defs.ex
···
1
+
defmodule Com.Atproto.Repo.Defs do
2
+
@moduledoc false
3
+
use Atex.Lexicon
4
+
5
+
deflexicon(%{
6
+
"defs" => %{
7
+
"commitMeta" => %{
8
+
"properties" => %{
9
+
"cid" => %{"format" => "cid", "type" => "string"},
10
+
"rev" => %{"format" => "tid", "type" => "string"}
11
+
},
12
+
"required" => ["cid", "rev"],
13
+
"type" => "object"
14
+
}
15
+
},
16
+
"id" => "com.atproto.repo.defs",
17
+
"lexicon" => 1
18
+
})
19
+
end
+63
lib/atproto/com/atproto/repo/deleteRecord.ex
+63
lib/atproto/com/atproto/repo/deleteRecord.ex
···
1
+
defmodule Com.Atproto.Repo.DeleteRecord do
2
+
@moduledoc false
3
+
use Atex.Lexicon
4
+
5
+
deflexicon(%{
6
+
"defs" => %{
7
+
"main" => %{
8
+
"description" =>
9
+
"Delete a repository record, or ensure it doesn't exist. Requires auth, implemented by PDS.",
10
+
"errors" => [%{"name" => "InvalidSwap"}],
11
+
"input" => %{
12
+
"encoding" => "application/json",
13
+
"schema" => %{
14
+
"properties" => %{
15
+
"collection" => %{
16
+
"description" => "The NSID of the record collection.",
17
+
"format" => "nsid",
18
+
"type" => "string"
19
+
},
20
+
"repo" => %{
21
+
"description" => "The handle or DID of the repo (aka, current account).",
22
+
"format" => "at-identifier",
23
+
"type" => "string"
24
+
},
25
+
"rkey" => %{
26
+
"description" => "The Record Key.",
27
+
"format" => "record-key",
28
+
"type" => "string"
29
+
},
30
+
"swapCommit" => %{
31
+
"description" => "Compare and swap with the previous commit by CID.",
32
+
"format" => "cid",
33
+
"type" => "string"
34
+
},
35
+
"swapRecord" => %{
36
+
"description" => "Compare and swap with the previous record by CID.",
37
+
"format" => "cid",
38
+
"type" => "string"
39
+
}
40
+
},
41
+
"required" => ["repo", "collection", "rkey"],
42
+
"type" => "object"
43
+
}
44
+
},
45
+
"output" => %{
46
+
"encoding" => "application/json",
47
+
"schema" => %{
48
+
"properties" => %{
49
+
"commit" => %{
50
+
"ref" => "com.atproto.repo.defs#commitMeta",
51
+
"type" => "ref"
52
+
}
53
+
},
54
+
"type" => "object"
55
+
}
56
+
},
57
+
"type" => "procedure"
58
+
}
59
+
},
60
+
"id" => "com.atproto.repo.deleteRecord",
61
+
"lexicon" => 1
62
+
})
63
+
end
+53
lib/atproto/com/atproto/repo/describeRepo.ex
+53
lib/atproto/com/atproto/repo/describeRepo.ex
···
1
+
defmodule Com.Atproto.Repo.DescribeRepo do
2
+
@moduledoc false
3
+
use Atex.Lexicon
4
+
5
+
deflexicon(%{
6
+
"defs" => %{
7
+
"main" => %{
8
+
"description" =>
9
+
"Get information about an account and repository, including the list of collections. Does not require auth.",
10
+
"output" => %{
11
+
"encoding" => "application/json",
12
+
"schema" => %{
13
+
"properties" => %{
14
+
"collections" => %{
15
+
"description" =>
16
+
"List of all the collections (NSIDs) for which this repo contains at least one record.",
17
+
"items" => %{"format" => "nsid", "type" => "string"},
18
+
"type" => "array"
19
+
},
20
+
"did" => %{"format" => "did", "type" => "string"},
21
+
"didDoc" => %{
22
+
"description" => "The complete DID document for this account.",
23
+
"type" => "unknown"
24
+
},
25
+
"handle" => %{"format" => "handle", "type" => "string"},
26
+
"handleIsCorrect" => %{
27
+
"description" =>
28
+
"Indicates if handle is currently valid (resolves bi-directionally)",
29
+
"type" => "boolean"
30
+
}
31
+
},
32
+
"required" => ["handle", "did", "didDoc", "collections", "handleIsCorrect"],
33
+
"type" => "object"
34
+
}
35
+
},
36
+
"parameters" => %{
37
+
"properties" => %{
38
+
"repo" => %{
39
+
"description" => "The handle or DID of the repo.",
40
+
"format" => "at-identifier",
41
+
"type" => "string"
42
+
}
43
+
},
44
+
"required" => ["repo"],
45
+
"type" => "params"
46
+
},
47
+
"type" => "query"
48
+
}
49
+
},
50
+
"id" => "com.atproto.repo.describeRepo",
51
+
"lexicon" => 1
52
+
})
53
+
end
+55
lib/atproto/com/atproto/repo/getRecord.ex
+55
lib/atproto/com/atproto/repo/getRecord.ex
···
1
+
defmodule Com.Atproto.Repo.GetRecord do
2
+
@moduledoc false
3
+
use Atex.Lexicon
4
+
5
+
deflexicon(%{
6
+
"defs" => %{
7
+
"main" => %{
8
+
"description" => "Get a single record from a repository. Does not require auth.",
9
+
"errors" => [%{"name" => "RecordNotFound"}],
10
+
"output" => %{
11
+
"encoding" => "application/json",
12
+
"schema" => %{
13
+
"properties" => %{
14
+
"cid" => %{"format" => "cid", "type" => "string"},
15
+
"uri" => %{"format" => "at-uri", "type" => "string"},
16
+
"value" => %{"type" => "unknown"}
17
+
},
18
+
"required" => ["uri", "value"],
19
+
"type" => "object"
20
+
}
21
+
},
22
+
"parameters" => %{
23
+
"properties" => %{
24
+
"cid" => %{
25
+
"description" =>
26
+
"The CID of the version of the record. If not specified, then return the most recent version.",
27
+
"format" => "cid",
28
+
"type" => "string"
29
+
},
30
+
"collection" => %{
31
+
"description" => "The NSID of the record collection.",
32
+
"format" => "nsid",
33
+
"type" => "string"
34
+
},
35
+
"repo" => %{
36
+
"description" => "The handle or DID of the repo.",
37
+
"format" => "at-identifier",
38
+
"type" => "string"
39
+
},
40
+
"rkey" => %{
41
+
"description" => "The Record Key.",
42
+
"format" => "record-key",
43
+
"type" => "string"
44
+
}
45
+
},
46
+
"required" => ["repo", "collection", "rkey"],
47
+
"type" => "params"
48
+
},
49
+
"type" => "query"
50
+
}
51
+
},
52
+
"id" => "com.atproto.repo.getRecord",
53
+
"lexicon" => 1
54
+
})
55
+
end
+17
lib/atproto/com/atproto/repo/importRepo.ex
+17
lib/atproto/com/atproto/repo/importRepo.ex
···
1
+
defmodule Com.Atproto.Repo.ImportRepo do
2
+
@moduledoc false
3
+
use Atex.Lexicon
4
+
5
+
deflexicon(%{
6
+
"defs" => %{
7
+
"main" => %{
8
+
"description" =>
9
+
"Import a repo in the form of a CAR file. Requires Content-Length HTTP header to be set.",
10
+
"input" => %{"encoding" => "application/vnd.ipld.car"},
11
+
"type" => "procedure"
12
+
}
13
+
},
14
+
"id" => "com.atproto.repo.importRepo",
15
+
"lexicon" => 1
16
+
})
17
+
end
+50
lib/atproto/com/atproto/repo/listMissingBlobs.ex
+50
lib/atproto/com/atproto/repo/listMissingBlobs.ex
···
1
+
defmodule Com.Atproto.Repo.ListMissingBlobs do
2
+
@moduledoc false
3
+
use Atex.Lexicon
4
+
5
+
deflexicon(%{
6
+
"defs" => %{
7
+
"main" => %{
8
+
"description" =>
9
+
"Returns a list of missing blobs for the requesting account. Intended to be used in the account migration flow.",
10
+
"output" => %{
11
+
"encoding" => "application/json",
12
+
"schema" => %{
13
+
"properties" => %{
14
+
"blobs" => %{
15
+
"items" => %{"ref" => "#recordBlob", "type" => "ref"},
16
+
"type" => "array"
17
+
},
18
+
"cursor" => %{"type" => "string"}
19
+
},
20
+
"required" => ["blobs"],
21
+
"type" => "object"
22
+
}
23
+
},
24
+
"parameters" => %{
25
+
"properties" => %{
26
+
"cursor" => %{"type" => "string"},
27
+
"limit" => %{
28
+
"default" => 500,
29
+
"maximum" => 1000,
30
+
"minimum" => 1,
31
+
"type" => "integer"
32
+
}
33
+
},
34
+
"type" => "params"
35
+
},
36
+
"type" => "query"
37
+
},
38
+
"recordBlob" => %{
39
+
"properties" => %{
40
+
"cid" => %{"format" => "cid", "type" => "string"},
41
+
"recordUri" => %{"format" => "at-uri", "type" => "string"}
42
+
},
43
+
"required" => ["cid", "recordUri"],
44
+
"type" => "object"
45
+
}
46
+
},
47
+
"id" => "com.atproto.repo.listMissingBlobs",
48
+
"lexicon" => 1
49
+
})
50
+
end
+67
lib/atproto/com/atproto/repo/listRecords.ex
+67
lib/atproto/com/atproto/repo/listRecords.ex
···
1
+
defmodule Com.Atproto.Repo.ListRecords do
2
+
@moduledoc false
3
+
use Atex.Lexicon
4
+
5
+
deflexicon(%{
6
+
"defs" => %{
7
+
"main" => %{
8
+
"description" =>
9
+
"List a range of records in a repository, matching a specific collection. Does not require auth.",
10
+
"output" => %{
11
+
"encoding" => "application/json",
12
+
"schema" => %{
13
+
"properties" => %{
14
+
"cursor" => %{"type" => "string"},
15
+
"records" => %{
16
+
"items" => %{"ref" => "#record", "type" => "ref"},
17
+
"type" => "array"
18
+
}
19
+
},
20
+
"required" => ["records"],
21
+
"type" => "object"
22
+
}
23
+
},
24
+
"parameters" => %{
25
+
"properties" => %{
26
+
"collection" => %{
27
+
"description" => "The NSID of the record type.",
28
+
"format" => "nsid",
29
+
"type" => "string"
30
+
},
31
+
"cursor" => %{"type" => "string"},
32
+
"limit" => %{
33
+
"default" => 50,
34
+
"description" => "The number of records to return.",
35
+
"maximum" => 100,
36
+
"minimum" => 1,
37
+
"type" => "integer"
38
+
},
39
+
"repo" => %{
40
+
"description" => "The handle or DID of the repo.",
41
+
"format" => "at-identifier",
42
+
"type" => "string"
43
+
},
44
+
"reverse" => %{
45
+
"description" => "Flag to reverse the order of the returned records.",
46
+
"type" => "boolean"
47
+
}
48
+
},
49
+
"required" => ["repo", "collection"],
50
+
"type" => "params"
51
+
},
52
+
"type" => "query"
53
+
},
54
+
"record" => %{
55
+
"properties" => %{
56
+
"cid" => %{"format" => "cid", "type" => "string"},
57
+
"uri" => %{"format" => "at-uri", "type" => "string"},
58
+
"value" => %{"type" => "unknown"}
59
+
},
60
+
"required" => ["uri", "cid", "value"],
61
+
"type" => "object"
62
+
}
63
+
},
64
+
"id" => "com.atproto.repo.listRecords",
65
+
"lexicon" => 1
66
+
})
67
+
end
+82
lib/atproto/com/atproto/repo/putRecord.ex
+82
lib/atproto/com/atproto/repo/putRecord.ex
···
1
+
defmodule Com.Atproto.Repo.PutRecord do
2
+
@moduledoc false
3
+
use Atex.Lexicon
4
+
5
+
deflexicon(%{
6
+
"defs" => %{
7
+
"main" => %{
8
+
"description" =>
9
+
"Write a repository record, creating or updating it as needed. Requires auth, implemented by PDS.",
10
+
"errors" => [%{"name" => "InvalidSwap"}],
11
+
"input" => %{
12
+
"encoding" => "application/json",
13
+
"schema" => %{
14
+
"nullable" => ["swapRecord"],
15
+
"properties" => %{
16
+
"collection" => %{
17
+
"description" => "The NSID of the record collection.",
18
+
"format" => "nsid",
19
+
"type" => "string"
20
+
},
21
+
"record" => %{
22
+
"description" => "The record to write.",
23
+
"type" => "unknown"
24
+
},
25
+
"repo" => %{
26
+
"description" => "The handle or DID of the repo (aka, current account).",
27
+
"format" => "at-identifier",
28
+
"type" => "string"
29
+
},
30
+
"rkey" => %{
31
+
"description" => "The Record Key.",
32
+
"format" => "record-key",
33
+
"maxLength" => 512,
34
+
"type" => "string"
35
+
},
36
+
"swapCommit" => %{
37
+
"description" => "Compare and swap with the previous commit by CID.",
38
+
"format" => "cid",
39
+
"type" => "string"
40
+
},
41
+
"swapRecord" => %{
42
+
"description" =>
43
+
"Compare and swap with the previous record by CID. WARNING: nullable and optional field; may cause problems with golang implementation",
44
+
"format" => "cid",
45
+
"type" => "string"
46
+
},
47
+
"validate" => %{
48
+
"description" =>
49
+
"Can be set to 'false' to skip Lexicon schema validation of record data, 'true' to require it, or leave unset to validate only for known Lexicons.",
50
+
"type" => "boolean"
51
+
}
52
+
},
53
+
"required" => ["repo", "collection", "rkey", "record"],
54
+
"type" => "object"
55
+
}
56
+
},
57
+
"output" => %{
58
+
"encoding" => "application/json",
59
+
"schema" => %{
60
+
"properties" => %{
61
+
"cid" => %{"format" => "cid", "type" => "string"},
62
+
"commit" => %{
63
+
"ref" => "com.atproto.repo.defs#commitMeta",
64
+
"type" => "ref"
65
+
},
66
+
"uri" => %{"format" => "at-uri", "type" => "string"},
67
+
"validationStatus" => %{
68
+
"knownValues" => ["valid", "unknown"],
69
+
"type" => "string"
70
+
}
71
+
},
72
+
"required" => ["uri", "cid"],
73
+
"type" => "object"
74
+
}
75
+
},
76
+
"type" => "procedure"
77
+
}
78
+
},
79
+
"id" => "com.atproto.repo.putRecord",
80
+
"lexicon" => 1
81
+
})
82
+
end
+20
lib/atproto/com/atproto/repo/strongRef.ex
+20
lib/atproto/com/atproto/repo/strongRef.ex
···
1
+
defmodule Com.Atproto.Repo.StrongRef do
2
+
@moduledoc false
3
+
use Atex.Lexicon
4
+
5
+
deflexicon(%{
6
+
"defs" => %{
7
+
"main" => %{
8
+
"properties" => %{
9
+
"cid" => %{"format" => "cid", "type" => "string"},
10
+
"uri" => %{"format" => "at-uri", "type" => "string"}
11
+
},
12
+
"required" => ["uri", "cid"],
13
+
"type" => "object"
14
+
}
15
+
},
16
+
"description" => "A URI with a content-hash fingerprint.",
17
+
"id" => "com.atproto.repo.strongRef",
18
+
"lexicon" => 1
19
+
})
20
+
end
+25
lib/atproto/com/atproto/repo/uploadBlob.ex
+25
lib/atproto/com/atproto/repo/uploadBlob.ex
···
1
+
defmodule Com.Atproto.Repo.UploadBlob do
2
+
@moduledoc false
3
+
use Atex.Lexicon
4
+
5
+
deflexicon(%{
6
+
"defs" => %{
7
+
"main" => %{
8
+
"description" =>
9
+
"Upload a new blob, to be referenced from a repository record. The blob will be deleted if it is not referenced within a time window (eg, minutes). Blob restrictions (mimetype, size, etc) are enforced when the reference is created. Requires auth, implemented by PDS.",
10
+
"input" => %{"encoding" => "*/*"},
11
+
"output" => %{
12
+
"encoding" => "application/json",
13
+
"schema" => %{
14
+
"properties" => %{"blob" => %{"type" => "blob"}},
15
+
"required" => ["blob"],
16
+
"type" => "object"
17
+
}
18
+
},
19
+
"type" => "procedure"
20
+
}
21
+
},
22
+
"id" => "com.atproto.repo.uploadBlob",
23
+
"lexicon" => 1
24
+
})
25
+
end
+16
lib/atproto/com/atproto/server/activateAccount.ex
+16
lib/atproto/com/atproto/server/activateAccount.ex
···
1
+
defmodule Com.Atproto.Server.ActivateAccount do
2
+
@moduledoc false
3
+
use Atex.Lexicon
4
+
5
+
deflexicon(%{
6
+
"defs" => %{
7
+
"main" => %{
8
+
"description" =>
9
+
"Activates a currently deactivated account. Used to finalize account migration after the account's repo is imported and identity is setup.",
10
+
"type" => "procedure"
11
+
}
12
+
},
13
+
"id" => "com.atproto.server.activateAccount",
14
+
"lexicon" => 1
15
+
})
16
+
end
+44
lib/atproto/com/atproto/server/checkAccountStatus.ex
+44
lib/atproto/com/atproto/server/checkAccountStatus.ex
···
1
+
defmodule Com.Atproto.Server.CheckAccountStatus do
2
+
@moduledoc false
3
+
use Atex.Lexicon
4
+
5
+
deflexicon(%{
6
+
"defs" => %{
7
+
"main" => %{
8
+
"description" =>
9
+
"Returns the status of an account, especially as pertaining to import or recovery. Can be called many times over the course of an account migration. Requires auth and can only be called pertaining to oneself.",
10
+
"output" => %{
11
+
"encoding" => "application/json",
12
+
"schema" => %{
13
+
"properties" => %{
14
+
"activated" => %{"type" => "boolean"},
15
+
"expectedBlobs" => %{"type" => "integer"},
16
+
"importedBlobs" => %{"type" => "integer"},
17
+
"indexedRecords" => %{"type" => "integer"},
18
+
"privateStateValues" => %{"type" => "integer"},
19
+
"repoBlocks" => %{"type" => "integer"},
20
+
"repoCommit" => %{"format" => "cid", "type" => "string"},
21
+
"repoRev" => %{"type" => "string"},
22
+
"validDid" => %{"type" => "boolean"}
23
+
},
24
+
"required" => [
25
+
"activated",
26
+
"validDid",
27
+
"repoCommit",
28
+
"repoRev",
29
+
"repoBlocks",
30
+
"indexedRecords",
31
+
"privateStateValues",
32
+
"expectedBlobs",
33
+
"importedBlobs"
34
+
],
35
+
"type" => "object"
36
+
}
37
+
},
38
+
"type" => "query"
39
+
}
40
+
},
41
+
"id" => "com.atproto.server.checkAccountStatus",
42
+
"lexicon" => 1
43
+
})
44
+
end
+33
lib/atproto/com/atproto/server/confirmEmail.ex
+33
lib/atproto/com/atproto/server/confirmEmail.ex
···
1
+
defmodule Com.Atproto.Server.ConfirmEmail do
2
+
@moduledoc false
3
+
use Atex.Lexicon
4
+
5
+
deflexicon(%{
6
+
"defs" => %{
7
+
"main" => %{
8
+
"description" =>
9
+
"Confirm an email using a token from com.atproto.server.requestEmailConfirmation.",
10
+
"errors" => [
11
+
%{"name" => "AccountNotFound"},
12
+
%{"name" => "ExpiredToken"},
13
+
%{"name" => "InvalidToken"},
14
+
%{"name" => "InvalidEmail"}
15
+
],
16
+
"input" => %{
17
+
"encoding" => "application/json",
18
+
"schema" => %{
19
+
"properties" => %{
20
+
"email" => %{"type" => "string"},
21
+
"token" => %{"type" => "string"}
22
+
},
23
+
"required" => ["email", "token"],
24
+
"type" => "object"
25
+
}
26
+
},
27
+
"type" => "procedure"
28
+
}
29
+
},
30
+
"id" => "com.atproto.server.confirmEmail",
31
+
"lexicon" => 1
32
+
})
33
+
end
+84
lib/atproto/com/atproto/server/createAccount.ex
+84
lib/atproto/com/atproto/server/createAccount.ex
···
1
+
defmodule Com.Atproto.Server.CreateAccount do
2
+
@moduledoc false
3
+
use Atex.Lexicon
4
+
5
+
deflexicon(%{
6
+
"defs" => %{
7
+
"main" => %{
8
+
"description" => "Create an account. Implemented by PDS.",
9
+
"errors" => [
10
+
%{"name" => "InvalidHandle"},
11
+
%{"name" => "InvalidPassword"},
12
+
%{"name" => "InvalidInviteCode"},
13
+
%{"name" => "HandleNotAvailable"},
14
+
%{"name" => "UnsupportedDomain"},
15
+
%{"name" => "UnresolvableDid"},
16
+
%{"name" => "IncompatibleDidDoc"}
17
+
],
18
+
"input" => %{
19
+
"encoding" => "application/json",
20
+
"schema" => %{
21
+
"properties" => %{
22
+
"did" => %{
23
+
"description" => "Pre-existing atproto DID, being imported to a new account.",
24
+
"format" => "did",
25
+
"type" => "string"
26
+
},
27
+
"email" => %{"type" => "string"},
28
+
"handle" => %{
29
+
"description" => "Requested handle for the account.",
30
+
"format" => "handle",
31
+
"type" => "string"
32
+
},
33
+
"inviteCode" => %{"type" => "string"},
34
+
"password" => %{
35
+
"description" =>
36
+
"Initial account password. May need to meet instance-specific password strength requirements.",
37
+
"type" => "string"
38
+
},
39
+
"plcOp" => %{
40
+
"description" =>
41
+
"A signed DID PLC operation to be submitted as part of importing an existing account to this instance. NOTE: this optional field may be updated when full account migration is implemented.",
42
+
"type" => "unknown"
43
+
},
44
+
"recoveryKey" => %{
45
+
"description" =>
46
+
"DID PLC rotation key (aka, recovery key) to be included in PLC creation operation.",
47
+
"type" => "string"
48
+
},
49
+
"verificationCode" => %{"type" => "string"},
50
+
"verificationPhone" => %{"type" => "string"}
51
+
},
52
+
"required" => ["handle"],
53
+
"type" => "object"
54
+
}
55
+
},
56
+
"output" => %{
57
+
"encoding" => "application/json",
58
+
"schema" => %{
59
+
"description" => "Account login session returned on successful account creation.",
60
+
"properties" => %{
61
+
"accessJwt" => %{"type" => "string"},
62
+
"did" => %{
63
+
"description" => "The DID of the new account.",
64
+
"format" => "did",
65
+
"type" => "string"
66
+
},
67
+
"didDoc" => %{
68
+
"description" => "Complete DID document.",
69
+
"type" => "unknown"
70
+
},
71
+
"handle" => %{"format" => "handle", "type" => "string"},
72
+
"refreshJwt" => %{"type" => "string"}
73
+
},
74
+
"required" => ["accessJwt", "refreshJwt", "handle", "did"],
75
+
"type" => "object"
76
+
}
77
+
},
78
+
"type" => "procedure"
79
+
}
80
+
},
81
+
"id" => "com.atproto.server.createAccount",
82
+
"lexicon" => 1
83
+
})
84
+
end
+48
lib/atproto/com/atproto/server/createAppPassword.ex
+48
lib/atproto/com/atproto/server/createAppPassword.ex
···
1
+
defmodule Com.Atproto.Server.CreateAppPassword do
2
+
@moduledoc false
3
+
use Atex.Lexicon
4
+
5
+
deflexicon(%{
6
+
"defs" => %{
7
+
"appPassword" => %{
8
+
"properties" => %{
9
+
"createdAt" => %{"format" => "datetime", "type" => "string"},
10
+
"name" => %{"type" => "string"},
11
+
"password" => %{"type" => "string"},
12
+
"privileged" => %{"type" => "boolean"}
13
+
},
14
+
"required" => ["name", "password", "createdAt"],
15
+
"type" => "object"
16
+
},
17
+
"main" => %{
18
+
"description" => "Create an App Password.",
19
+
"errors" => [%{"name" => "AccountTakedown"}],
20
+
"input" => %{
21
+
"encoding" => "application/json",
22
+
"schema" => %{
23
+
"properties" => %{
24
+
"name" => %{
25
+
"description" => "A short name for the App Password, to help distinguish them.",
26
+
"type" => "string"
27
+
},
28
+
"privileged" => %{
29
+
"description" =>
30
+
"If an app password has 'privileged' access to possibly sensitive account state. Meant for use with trusted clients.",
31
+
"type" => "boolean"
32
+
}
33
+
},
34
+
"required" => ["name"],
35
+
"type" => "object"
36
+
}
37
+
},
38
+
"output" => %{
39
+
"encoding" => "application/json",
40
+
"schema" => %{"ref" => "#appPassword", "type" => "ref"}
41
+
},
42
+
"type" => "procedure"
43
+
}
44
+
},
45
+
"id" => "com.atproto.server.createAppPassword",
46
+
"lexicon" => 1
47
+
})
48
+
end
+34
lib/atproto/com/atproto/server/createInviteCode.ex
+34
lib/atproto/com/atproto/server/createInviteCode.ex
···
1
+
defmodule Com.Atproto.Server.CreateInviteCode do
2
+
@moduledoc false
3
+
use Atex.Lexicon
4
+
5
+
deflexicon(%{
6
+
"defs" => %{
7
+
"main" => %{
8
+
"description" => "Create an invite code.",
9
+
"input" => %{
10
+
"encoding" => "application/json",
11
+
"schema" => %{
12
+
"properties" => %{
13
+
"forAccount" => %{"format" => "did", "type" => "string"},
14
+
"useCount" => %{"type" => "integer"}
15
+
},
16
+
"required" => ["useCount"],
17
+
"type" => "object"
18
+
}
19
+
},
20
+
"output" => %{
21
+
"encoding" => "application/json",
22
+
"schema" => %{
23
+
"properties" => %{"code" => %{"type" => "string"}},
24
+
"required" => ["code"],
25
+
"type" => "object"
26
+
}
27
+
},
28
+
"type" => "procedure"
29
+
}
30
+
},
31
+
"id" => "com.atproto.server.createInviteCode",
32
+
"lexicon" => 1
33
+
})
34
+
end
+51
lib/atproto/com/atproto/server/createInviteCodes.ex
+51
lib/atproto/com/atproto/server/createInviteCodes.ex
···
1
+
defmodule Com.Atproto.Server.CreateInviteCodes do
2
+
@moduledoc false
3
+
use Atex.Lexicon
4
+
5
+
deflexicon(%{
6
+
"defs" => %{
7
+
"accountCodes" => %{
8
+
"properties" => %{
9
+
"account" => %{"type" => "string"},
10
+
"codes" => %{"items" => %{"type" => "string"}, "type" => "array"}
11
+
},
12
+
"required" => ["account", "codes"],
13
+
"type" => "object"
14
+
},
15
+
"main" => %{
16
+
"description" => "Create invite codes.",
17
+
"input" => %{
18
+
"encoding" => "application/json",
19
+
"schema" => %{
20
+
"properties" => %{
21
+
"codeCount" => %{"default" => 1, "type" => "integer"},
22
+
"forAccounts" => %{
23
+
"items" => %{"format" => "did", "type" => "string"},
24
+
"type" => "array"
25
+
},
26
+
"useCount" => %{"type" => "integer"}
27
+
},
28
+
"required" => ["codeCount", "useCount"],
29
+
"type" => "object"
30
+
}
31
+
},
32
+
"output" => %{
33
+
"encoding" => "application/json",
34
+
"schema" => %{
35
+
"properties" => %{
36
+
"codes" => %{
37
+
"items" => %{"ref" => "#accountCodes", "type" => "ref"},
38
+
"type" => "array"
39
+
}
40
+
},
41
+
"required" => ["codes"],
42
+
"type" => "object"
43
+
}
44
+
},
45
+
"type" => "procedure"
46
+
}
47
+
},
48
+
"id" => "com.atproto.server.createInviteCodes",
49
+
"lexicon" => 1
50
+
})
51
+
end
+64
lib/atproto/com/atproto/server/createSession.ex
+64
lib/atproto/com/atproto/server/createSession.ex
···
1
+
defmodule Com.Atproto.Server.CreateSession do
2
+
@moduledoc false
3
+
use Atex.Lexicon
4
+
5
+
deflexicon(%{
6
+
"defs" => %{
7
+
"main" => %{
8
+
"description" => "Create an authentication session.",
9
+
"errors" => [
10
+
%{"name" => "AccountTakedown"},
11
+
%{"name" => "AuthFactorTokenRequired"}
12
+
],
13
+
"input" => %{
14
+
"encoding" => "application/json",
15
+
"schema" => %{
16
+
"properties" => %{
17
+
"allowTakendown" => %{
18
+
"description" =>
19
+
"When true, instead of throwing error for takendown accounts, a valid response with a narrow scoped token will be returned",
20
+
"type" => "boolean"
21
+
},
22
+
"authFactorToken" => %{"type" => "string"},
23
+
"identifier" => %{
24
+
"description" =>
25
+
"Handle or other identifier supported by the server for the authenticating user.",
26
+
"type" => "string"
27
+
},
28
+
"password" => %{"type" => "string"}
29
+
},
30
+
"required" => ["identifier", "password"],
31
+
"type" => "object"
32
+
}
33
+
},
34
+
"output" => %{
35
+
"encoding" => "application/json",
36
+
"schema" => %{
37
+
"properties" => %{
38
+
"accessJwt" => %{"type" => "string"},
39
+
"active" => %{"type" => "boolean"},
40
+
"did" => %{"format" => "did", "type" => "string"},
41
+
"didDoc" => %{"type" => "unknown"},
42
+
"email" => %{"type" => "string"},
43
+
"emailAuthFactor" => %{"type" => "boolean"},
44
+
"emailConfirmed" => %{"type" => "boolean"},
45
+
"handle" => %{"format" => "handle", "type" => "string"},
46
+
"refreshJwt" => %{"type" => "string"},
47
+
"status" => %{
48
+
"description" =>
49
+
"If active=false, this optional field indicates a possible reason for why the account is not active. If active=false and no status is supplied, then the host makes no claim for why the repository is no longer being hosted.",
50
+
"knownValues" => ["takendown", "suspended", "deactivated"],
51
+
"type" => "string"
52
+
}
53
+
},
54
+
"required" => ["accessJwt", "refreshJwt", "handle", "did"],
55
+
"type" => "object"
56
+
}
57
+
},
58
+
"type" => "procedure"
59
+
}
60
+
},
61
+
"id" => "com.atproto.server.createSession",
62
+
"lexicon" => 1
63
+
})
64
+
end
+30
lib/atproto/com/atproto/server/deactivateAccount.ex
+30
lib/atproto/com/atproto/server/deactivateAccount.ex
···
1
+
defmodule Com.Atproto.Server.DeactivateAccount do
2
+
@moduledoc false
3
+
use Atex.Lexicon
4
+
5
+
deflexicon(%{
6
+
"defs" => %{
7
+
"main" => %{
8
+
"description" =>
9
+
"Deactivates a currently active account. Stops serving of repo, and future writes to repo until reactivated. Used to finalize account migration with the old host after the account has been activated on the new host.",
10
+
"input" => %{
11
+
"encoding" => "application/json",
12
+
"schema" => %{
13
+
"properties" => %{
14
+
"deleteAfter" => %{
15
+
"description" =>
16
+
"A recommendation to server as to how long they should hold onto the deactivated account before deleting.",
17
+
"format" => "datetime",
18
+
"type" => "string"
19
+
}
20
+
},
21
+
"type" => "object"
22
+
}
23
+
},
24
+
"type" => "procedure"
25
+
}
26
+
},
27
+
"id" => "com.atproto.server.deactivateAccount",
28
+
"lexicon" => 1
29
+
})
30
+
end
+43
lib/atproto/com/atproto/server/defs.ex
+43
lib/atproto/com/atproto/server/defs.ex
···
1
+
defmodule Com.Atproto.Server.Defs do
2
+
@moduledoc false
3
+
use Atex.Lexicon
4
+
5
+
deflexicon(%{
6
+
"defs" => %{
7
+
"inviteCode" => %{
8
+
"properties" => %{
9
+
"available" => %{"type" => "integer"},
10
+
"code" => %{"type" => "string"},
11
+
"createdAt" => %{"format" => "datetime", "type" => "string"},
12
+
"createdBy" => %{"type" => "string"},
13
+
"disabled" => %{"type" => "boolean"},
14
+
"forAccount" => %{"type" => "string"},
15
+
"uses" => %{
16
+
"items" => %{"ref" => "#inviteCodeUse", "type" => "ref"},
17
+
"type" => "array"
18
+
}
19
+
},
20
+
"required" => [
21
+
"code",
22
+
"available",
23
+
"disabled",
24
+
"forAccount",
25
+
"createdBy",
26
+
"createdAt",
27
+
"uses"
28
+
],
29
+
"type" => "object"
30
+
},
31
+
"inviteCodeUse" => %{
32
+
"properties" => %{
33
+
"usedAt" => %{"format" => "datetime", "type" => "string"},
34
+
"usedBy" => %{"format" => "did", "type" => "string"}
35
+
},
36
+
"required" => ["usedBy", "usedAt"],
37
+
"type" => "object"
38
+
}
39
+
},
40
+
"id" => "com.atproto.server.defs",
41
+
"lexicon" => 1
42
+
})
43
+
end
+29
lib/atproto/com/atproto/server/deleteAccount.ex
+29
lib/atproto/com/atproto/server/deleteAccount.ex
···
1
+
defmodule Com.Atproto.Server.DeleteAccount do
2
+
@moduledoc false
3
+
use Atex.Lexicon
4
+
5
+
deflexicon(%{
6
+
"defs" => %{
7
+
"main" => %{
8
+
"description" =>
9
+
"Delete an actor's account with a token and password. Can only be called after requesting a deletion token. Requires auth.",
10
+
"errors" => [%{"name" => "ExpiredToken"}, %{"name" => "InvalidToken"}],
11
+
"input" => %{
12
+
"encoding" => "application/json",
13
+
"schema" => %{
14
+
"properties" => %{
15
+
"did" => %{"format" => "did", "type" => "string"},
16
+
"password" => %{"type" => "string"},
17
+
"token" => %{"type" => "string"}
18
+
},
19
+
"required" => ["did", "password", "token"],
20
+
"type" => "object"
21
+
}
22
+
},
23
+
"type" => "procedure"
24
+
}
25
+
},
26
+
"id" => "com.atproto.server.deleteAccount",
27
+
"lexicon" => 1
28
+
})
29
+
end
+15
lib/atproto/com/atproto/server/deleteSession.ex
+15
lib/atproto/com/atproto/server/deleteSession.ex
···
1
+
defmodule Com.Atproto.Server.DeleteSession do
2
+
@moduledoc false
3
+
use Atex.Lexicon
4
+
5
+
deflexicon(%{
6
+
"defs" => %{
7
+
"main" => %{
8
+
"description" => "Delete the current session. Requires auth.",
9
+
"type" => "procedure"
10
+
}
11
+
},
12
+
"id" => "com.atproto.server.deleteSession",
13
+
"lexicon" => 1
14
+
})
15
+
end
+62
lib/atproto/com/atproto/server/describeServer.ex
+62
lib/atproto/com/atproto/server/describeServer.ex
···
1
+
defmodule Com.Atproto.Server.DescribeServer do
2
+
@moduledoc false
3
+
use Atex.Lexicon
4
+
5
+
deflexicon(%{
6
+
"defs" => %{
7
+
"contact" => %{
8
+
"properties" => %{"email" => %{"type" => "string"}},
9
+
"type" => "object"
10
+
},
11
+
"links" => %{
12
+
"properties" => %{
13
+
"privacyPolicy" => %{"format" => "uri", "type" => "string"},
14
+
"termsOfService" => %{"format" => "uri", "type" => "string"}
15
+
},
16
+
"type" => "object"
17
+
},
18
+
"main" => %{
19
+
"description" =>
20
+
"Describes the server's account creation requirements and capabilities. Implemented by PDS.",
21
+
"output" => %{
22
+
"encoding" => "application/json",
23
+
"schema" => %{
24
+
"properties" => %{
25
+
"availableUserDomains" => %{
26
+
"description" => "List of domain suffixes that can be used in account handles.",
27
+
"items" => %{"type" => "string"},
28
+
"type" => "array"
29
+
},
30
+
"contact" => %{
31
+
"description" => "Contact information",
32
+
"ref" => "#contact",
33
+
"type" => "ref"
34
+
},
35
+
"did" => %{"format" => "did", "type" => "string"},
36
+
"inviteCodeRequired" => %{
37
+
"description" =>
38
+
"If true, an invite code must be supplied to create an account on this instance.",
39
+
"type" => "boolean"
40
+
},
41
+
"links" => %{
42
+
"description" => "URLs of service policy documents.",
43
+
"ref" => "#links",
44
+
"type" => "ref"
45
+
},
46
+
"phoneVerificationRequired" => %{
47
+
"description" =>
48
+
"If true, a phone verification token must be supplied to create an account on this instance.",
49
+
"type" => "boolean"
50
+
}
51
+
},
52
+
"required" => ["did", "availableUserDomains"],
53
+
"type" => "object"
54
+
}
55
+
},
56
+
"type" => "query"
57
+
}
58
+
},
59
+
"id" => "com.atproto.server.describeServer",
60
+
"lexicon" => 1
61
+
})
62
+
end
+44
lib/atproto/com/atproto/server/getAccountInviteCodes.ex
+44
lib/atproto/com/atproto/server/getAccountInviteCodes.ex
···
1
+
defmodule Com.Atproto.Server.GetAccountInviteCodes do
2
+
@moduledoc false
3
+
use Atex.Lexicon
4
+
5
+
deflexicon(%{
6
+
"defs" => %{
7
+
"main" => %{
8
+
"description" => "Get all invite codes for the current account. Requires auth.",
9
+
"errors" => [%{"name" => "DuplicateCreate"}],
10
+
"output" => %{
11
+
"encoding" => "application/json",
12
+
"schema" => %{
13
+
"properties" => %{
14
+
"codes" => %{
15
+
"items" => %{
16
+
"ref" => "com.atproto.server.defs#inviteCode",
17
+
"type" => "ref"
18
+
},
19
+
"type" => "array"
20
+
}
21
+
},
22
+
"required" => ["codes"],
23
+
"type" => "object"
24
+
}
25
+
},
26
+
"parameters" => %{
27
+
"properties" => %{
28
+
"createAvailable" => %{
29
+
"default" => true,
30
+
"description" =>
31
+
"Controls whether any new 'earned' but not 'created' invites should be created.",
32
+
"type" => "boolean"
33
+
},
34
+
"includeUsed" => %{"default" => true, "type" => "boolean"}
35
+
},
36
+
"type" => "params"
37
+
},
38
+
"type" => "query"
39
+
}
40
+
},
41
+
"id" => "com.atproto.server.getAccountInviteCodes",
42
+
"lexicon" => 1
43
+
})
44
+
end
+53
lib/atproto/com/atproto/server/getServiceAuth.ex
+53
lib/atproto/com/atproto/server/getServiceAuth.ex
···
1
+
defmodule Com.Atproto.Server.GetServiceAuth do
2
+
@moduledoc false
3
+
use Atex.Lexicon
4
+
5
+
deflexicon(%{
6
+
"defs" => %{
7
+
"main" => %{
8
+
"description" =>
9
+
"Get a signed token on behalf of the requesting DID for the requested service.",
10
+
"errors" => [
11
+
%{
12
+
"description" =>
13
+
"Indicates that the requested expiration date is not a valid. May be in the past or may be reliant on the requested scopes.",
14
+
"name" => "BadExpiration"
15
+
}
16
+
],
17
+
"output" => %{
18
+
"encoding" => "application/json",
19
+
"schema" => %{
20
+
"properties" => %{"token" => %{"type" => "string"}},
21
+
"required" => ["token"],
22
+
"type" => "object"
23
+
}
24
+
},
25
+
"parameters" => %{
26
+
"properties" => %{
27
+
"aud" => %{
28
+
"description" =>
29
+
"The DID of the service that the token will be used to authenticate with",
30
+
"format" => "did",
31
+
"type" => "string"
32
+
},
33
+
"exp" => %{
34
+
"description" =>
35
+
"The time in Unix Epoch seconds that the JWT expires. Defaults to 60 seconds in the future. The service may enforce certain time bounds on tokens depending on the requested scope.",
36
+
"type" => "integer"
37
+
},
38
+
"lxm" => %{
39
+
"description" => "Lexicon (XRPC) method to bind the requested token to",
40
+
"format" => "nsid",
41
+
"type" => "string"
42
+
}
43
+
},
44
+
"required" => ["aud"],
45
+
"type" => "params"
46
+
},
47
+
"type" => "query"
48
+
}
49
+
},
50
+
"id" => "com.atproto.server.getServiceAuth",
51
+
"lexicon" => 1
52
+
})
53
+
end
+37
lib/atproto/com/atproto/server/getSession.ex
+37
lib/atproto/com/atproto/server/getSession.ex
···
1
+
defmodule Com.Atproto.Server.GetSession do
2
+
@moduledoc false
3
+
use Atex.Lexicon
4
+
5
+
deflexicon(%{
6
+
"defs" => %{
7
+
"main" => %{
8
+
"description" => "Get information about the current auth session. Requires auth.",
9
+
"output" => %{
10
+
"encoding" => "application/json",
11
+
"schema" => %{
12
+
"properties" => %{
13
+
"active" => %{"type" => "boolean"},
14
+
"did" => %{"format" => "did", "type" => "string"},
15
+
"didDoc" => %{"type" => "unknown"},
16
+
"email" => %{"type" => "string"},
17
+
"emailAuthFactor" => %{"type" => "boolean"},
18
+
"emailConfirmed" => %{"type" => "boolean"},
19
+
"handle" => %{"format" => "handle", "type" => "string"},
20
+
"status" => %{
21
+
"description" =>
22
+
"If active=false, this optional field indicates a possible reason for why the account is not active. If active=false and no status is supplied, then the host makes no claim for why the repository is no longer being hosted.",
23
+
"knownValues" => ["takendown", "suspended", "deactivated"],
24
+
"type" => "string"
25
+
}
26
+
},
27
+
"required" => ["handle", "did"],
28
+
"type" => "object"
29
+
}
30
+
},
31
+
"type" => "query"
32
+
}
33
+
},
34
+
"id" => "com.atproto.server.getSession",
35
+
"lexicon" => 1
36
+
})
37
+
end
+38
lib/atproto/com/atproto/server/listAppPasswords.ex
+38
lib/atproto/com/atproto/server/listAppPasswords.ex
···
1
+
defmodule Com.Atproto.Server.ListAppPasswords do
2
+
@moduledoc false
3
+
use Atex.Lexicon
4
+
5
+
deflexicon(%{
6
+
"defs" => %{
7
+
"appPassword" => %{
8
+
"properties" => %{
9
+
"createdAt" => %{"format" => "datetime", "type" => "string"},
10
+
"name" => %{"type" => "string"},
11
+
"privileged" => %{"type" => "boolean"}
12
+
},
13
+
"required" => ["name", "createdAt"],
14
+
"type" => "object"
15
+
},
16
+
"main" => %{
17
+
"description" => "List all App Passwords.",
18
+
"errors" => [%{"name" => "AccountTakedown"}],
19
+
"output" => %{
20
+
"encoding" => "application/json",
21
+
"schema" => %{
22
+
"properties" => %{
23
+
"passwords" => %{
24
+
"items" => %{"ref" => "#appPassword", "type" => "ref"},
25
+
"type" => "array"
26
+
}
27
+
},
28
+
"required" => ["passwords"],
29
+
"type" => "object"
30
+
}
31
+
},
32
+
"type" => "query"
33
+
}
34
+
},
35
+
"id" => "com.atproto.server.listAppPasswords",
36
+
"lexicon" => 1
37
+
})
38
+
end
+38
lib/atproto/com/atproto/server/refreshSession.ex
+38
lib/atproto/com/atproto/server/refreshSession.ex
···
1
+
defmodule Com.Atproto.Server.RefreshSession do
2
+
@moduledoc false
3
+
use Atex.Lexicon
4
+
5
+
deflexicon(%{
6
+
"defs" => %{
7
+
"main" => %{
8
+
"description" =>
9
+
"Refresh an authentication session. Requires auth using the 'refreshJwt' (not the 'accessJwt').",
10
+
"errors" => [%{"name" => "AccountTakedown"}],
11
+
"output" => %{
12
+
"encoding" => "application/json",
13
+
"schema" => %{
14
+
"properties" => %{
15
+
"accessJwt" => %{"type" => "string"},
16
+
"active" => %{"type" => "boolean"},
17
+
"did" => %{"format" => "did", "type" => "string"},
18
+
"didDoc" => %{"type" => "unknown"},
19
+
"handle" => %{"format" => "handle", "type" => "string"},
20
+
"refreshJwt" => %{"type" => "string"},
21
+
"status" => %{
22
+
"description" =>
23
+
"Hosting status of the account. If not specified, then assume 'active'.",
24
+
"knownValues" => ["takendown", "suspended", "deactivated"],
25
+
"type" => "string"
26
+
}
27
+
},
28
+
"required" => ["accessJwt", "refreshJwt", "handle", "did"],
29
+
"type" => "object"
30
+
}
31
+
},
32
+
"type" => "procedure"
33
+
}
34
+
},
35
+
"id" => "com.atproto.server.refreshSession",
36
+
"lexicon" => 1
37
+
})
38
+
end
+15
lib/atproto/com/atproto/server/requestAccountDelete.ex
+15
lib/atproto/com/atproto/server/requestAccountDelete.ex
···
1
+
defmodule Com.Atproto.Server.RequestAccountDelete do
2
+
@moduledoc false
3
+
use Atex.Lexicon
4
+
5
+
deflexicon(%{
6
+
"defs" => %{
7
+
"main" => %{
8
+
"description" => "Initiate a user account deletion via email.",
9
+
"type" => "procedure"
10
+
}
11
+
},
12
+
"id" => "com.atproto.server.requestAccountDelete",
13
+
"lexicon" => 1
14
+
})
15
+
end
+15
lib/atproto/com/atproto/server/requestEmailConfirmation.ex
+15
lib/atproto/com/atproto/server/requestEmailConfirmation.ex
···
1
+
defmodule Com.Atproto.Server.RequestEmailConfirmation do
2
+
@moduledoc false
3
+
use Atex.Lexicon
4
+
5
+
deflexicon(%{
6
+
"defs" => %{
7
+
"main" => %{
8
+
"description" => "Request an email with a code to confirm ownership of email.",
9
+
"type" => "procedure"
10
+
}
11
+
},
12
+
"id" => "com.atproto.server.requestEmailConfirmation",
13
+
"lexicon" => 1
14
+
})
15
+
end
+23
lib/atproto/com/atproto/server/requestEmailUpdate.ex
+23
lib/atproto/com/atproto/server/requestEmailUpdate.ex
···
1
+
defmodule Com.Atproto.Server.RequestEmailUpdate do
2
+
@moduledoc false
3
+
use Atex.Lexicon
4
+
5
+
deflexicon(%{
6
+
"defs" => %{
7
+
"main" => %{
8
+
"description" => "Request a token in order to update email.",
9
+
"output" => %{
10
+
"encoding" => "application/json",
11
+
"schema" => %{
12
+
"properties" => %{"tokenRequired" => %{"type" => "boolean"}},
13
+
"required" => ["tokenRequired"],
14
+
"type" => "object"
15
+
}
16
+
},
17
+
"type" => "procedure"
18
+
}
19
+
},
20
+
"id" => "com.atproto.server.requestEmailUpdate",
21
+
"lexicon" => 1
22
+
})
23
+
end
+23
lib/atproto/com/atproto/server/requestPasswordReset.ex
+23
lib/atproto/com/atproto/server/requestPasswordReset.ex
···
1
+
defmodule Com.Atproto.Server.RequestPasswordReset do
2
+
@moduledoc false
3
+
use Atex.Lexicon
4
+
5
+
deflexicon(%{
6
+
"defs" => %{
7
+
"main" => %{
8
+
"description" => "Initiate a user account password reset via email.",
9
+
"input" => %{
10
+
"encoding" => "application/json",
11
+
"schema" => %{
12
+
"properties" => %{"email" => %{"type" => "string"}},
13
+
"required" => ["email"],
14
+
"type" => "object"
15
+
}
16
+
},
17
+
"type" => "procedure"
18
+
}
19
+
},
20
+
"id" => "com.atproto.server.requestPasswordReset",
21
+
"lexicon" => 1
22
+
})
23
+
end
+43
lib/atproto/com/atproto/server/reserveSigningKey.ex
+43
lib/atproto/com/atproto/server/reserveSigningKey.ex
···
1
+
defmodule Com.Atproto.Server.ReserveSigningKey do
2
+
@moduledoc false
3
+
use Atex.Lexicon
4
+
5
+
deflexicon(%{
6
+
"defs" => %{
7
+
"main" => %{
8
+
"description" =>
9
+
"Reserve a repo signing key, for use with account creation. Necessary so that a DID PLC update operation can be constructed during an account migraiton. Public and does not require auth; implemented by PDS. NOTE: this endpoint may change when full account migration is implemented.",
10
+
"input" => %{
11
+
"encoding" => "application/json",
12
+
"schema" => %{
13
+
"properties" => %{
14
+
"did" => %{
15
+
"description" => "The DID to reserve a key for.",
16
+
"format" => "did",
17
+
"type" => "string"
18
+
}
19
+
},
20
+
"type" => "object"
21
+
}
22
+
},
23
+
"output" => %{
24
+
"encoding" => "application/json",
25
+
"schema" => %{
26
+
"properties" => %{
27
+
"signingKey" => %{
28
+
"description" =>
29
+
"The public key for the reserved signing key, in did:key serialization.",
30
+
"type" => "string"
31
+
}
32
+
},
33
+
"required" => ["signingKey"],
34
+
"type" => "object"
35
+
}
36
+
},
37
+
"type" => "procedure"
38
+
}
39
+
},
40
+
"id" => "com.atproto.server.reserveSigningKey",
41
+
"lexicon" => 1
42
+
})
43
+
end
+27
lib/atproto/com/atproto/server/resetPassword.ex
+27
lib/atproto/com/atproto/server/resetPassword.ex
···
1
+
defmodule Com.Atproto.Server.ResetPassword do
2
+
@moduledoc false
3
+
use Atex.Lexicon
4
+
5
+
deflexicon(%{
6
+
"defs" => %{
7
+
"main" => %{
8
+
"description" => "Reset a user account password using a token.",
9
+
"errors" => [%{"name" => "ExpiredToken"}, %{"name" => "InvalidToken"}],
10
+
"input" => %{
11
+
"encoding" => "application/json",
12
+
"schema" => %{
13
+
"properties" => %{
14
+
"password" => %{"type" => "string"},
15
+
"token" => %{"type" => "string"}
16
+
},
17
+
"required" => ["token", "password"],
18
+
"type" => "object"
19
+
}
20
+
},
21
+
"type" => "procedure"
22
+
}
23
+
},
24
+
"id" => "com.atproto.server.resetPassword",
25
+
"lexicon" => 1
26
+
})
27
+
end
+23
lib/atproto/com/atproto/server/revokeAppPassword.ex
+23
lib/atproto/com/atproto/server/revokeAppPassword.ex
···
1
+
defmodule Com.Atproto.Server.RevokeAppPassword do
2
+
@moduledoc false
3
+
use Atex.Lexicon
4
+
5
+
deflexicon(%{
6
+
"defs" => %{
7
+
"main" => %{
8
+
"description" => "Revoke an App Password by name.",
9
+
"input" => %{
10
+
"encoding" => "application/json",
11
+
"schema" => %{
12
+
"properties" => %{"name" => %{"type" => "string"}},
13
+
"required" => ["name"],
14
+
"type" => "object"
15
+
}
16
+
},
17
+
"type" => "procedure"
18
+
}
19
+
},
20
+
"id" => "com.atproto.server.revokeAppPassword",
21
+
"lexicon" => 1
22
+
})
23
+
end
+36
lib/atproto/com/atproto/server/updateEmail.ex
+36
lib/atproto/com/atproto/server/updateEmail.ex
···
1
+
defmodule Com.Atproto.Server.UpdateEmail do
2
+
@moduledoc false
3
+
use Atex.Lexicon
4
+
5
+
deflexicon(%{
6
+
"defs" => %{
7
+
"main" => %{
8
+
"description" => "Update an account's email.",
9
+
"errors" => [
10
+
%{"name" => "ExpiredToken"},
11
+
%{"name" => "InvalidToken"},
12
+
%{"name" => "TokenRequired"}
13
+
],
14
+
"input" => %{
15
+
"encoding" => "application/json",
16
+
"schema" => %{
17
+
"properties" => %{
18
+
"email" => %{"type" => "string"},
19
+
"emailAuthFactor" => %{"type" => "boolean"},
20
+
"token" => %{
21
+
"description" =>
22
+
"Requires a token from com.atproto.sever.requestEmailUpdate if the account's email has been confirmed.",
23
+
"type" => "string"
24
+
}
25
+
},
26
+
"required" => ["email"],
27
+
"type" => "object"
28
+
}
29
+
},
30
+
"type" => "procedure"
31
+
}
32
+
},
33
+
"id" => "com.atproto.server.updateEmail",
34
+
"lexicon" => 1
35
+
})
36
+
end
+15
lib/atproto/com/atproto/sync/defs.ex
+15
lib/atproto/com/atproto/sync/defs.ex
···
1
+
defmodule Com.Atproto.Sync.Defs do
2
+
@moduledoc false
3
+
use Atex.Lexicon
4
+
5
+
deflexicon(%{
6
+
"defs" => %{
7
+
"hostStatus" => %{
8
+
"knownValues" => ["active", "idle", "offline", "throttled", "banned"],
9
+
"type" => "string"
10
+
}
11
+
},
12
+
"id" => "com.atproto.sync.defs",
13
+
"lexicon" => 1
14
+
})
15
+
end
+40
lib/atproto/com/atproto/sync/getBlob.ex
+40
lib/atproto/com/atproto/sync/getBlob.ex
···
1
+
defmodule Com.Atproto.Sync.GetBlob do
2
+
@moduledoc false
3
+
use Atex.Lexicon
4
+
5
+
deflexicon(%{
6
+
"defs" => %{
7
+
"main" => %{
8
+
"description" =>
9
+
"Get a blob associated with a given account. Returns the full blob as originally uploaded. Does not require auth; implemented by PDS.",
10
+
"errors" => [
11
+
%{"name" => "BlobNotFound"},
12
+
%{"name" => "RepoNotFound"},
13
+
%{"name" => "RepoTakendown"},
14
+
%{"name" => "RepoSuspended"},
15
+
%{"name" => "RepoDeactivated"}
16
+
],
17
+
"output" => %{"encoding" => "*/*"},
18
+
"parameters" => %{
19
+
"properties" => %{
20
+
"cid" => %{
21
+
"description" => "The CID of the blob to fetch",
22
+
"format" => "cid",
23
+
"type" => "string"
24
+
},
25
+
"did" => %{
26
+
"description" => "The DID of the account.",
27
+
"format" => "did",
28
+
"type" => "string"
29
+
}
30
+
},
31
+
"required" => ["did", "cid"],
32
+
"type" => "params"
33
+
},
34
+
"type" => "query"
35
+
}
36
+
},
37
+
"id" => "com.atproto.sync.getBlob",
38
+
"lexicon" => 1
39
+
})
40
+
end
+39
lib/atproto/com/atproto/sync/getBlocks.ex
+39
lib/atproto/com/atproto/sync/getBlocks.ex
···
1
+
defmodule Com.Atproto.Sync.GetBlocks do
2
+
@moduledoc false
3
+
use Atex.Lexicon
4
+
5
+
deflexicon(%{
6
+
"defs" => %{
7
+
"main" => %{
8
+
"description" =>
9
+
"Get data blocks from a given repo, by CID. For example, intermediate MST nodes, or records. Does not require auth; implemented by PDS.",
10
+
"errors" => [
11
+
%{"name" => "BlockNotFound"},
12
+
%{"name" => "RepoNotFound"},
13
+
%{"name" => "RepoTakendown"},
14
+
%{"name" => "RepoSuspended"},
15
+
%{"name" => "RepoDeactivated"}
16
+
],
17
+
"output" => %{"encoding" => "application/vnd.ipld.car"},
18
+
"parameters" => %{
19
+
"properties" => %{
20
+
"cids" => %{
21
+
"items" => %{"format" => "cid", "type" => "string"},
22
+
"type" => "array"
23
+
},
24
+
"did" => %{
25
+
"description" => "The DID of the repo.",
26
+
"format" => "did",
27
+
"type" => "string"
28
+
}
29
+
},
30
+
"required" => ["did", "cids"],
31
+
"type" => "params"
32
+
},
33
+
"type" => "query"
34
+
}
35
+
},
36
+
"id" => "com.atproto.sync.getBlocks",
37
+
"lexicon" => 1
38
+
})
39
+
end
+27
lib/atproto/com/atproto/sync/getCheckout.ex
+27
lib/atproto/com/atproto/sync/getCheckout.ex
···
1
+
defmodule Com.Atproto.Sync.GetCheckout do
2
+
@moduledoc false
3
+
use Atex.Lexicon
4
+
5
+
deflexicon(%{
6
+
"defs" => %{
7
+
"main" => %{
8
+
"description" => "DEPRECATED - please use com.atproto.sync.getRepo instead",
9
+
"output" => %{"encoding" => "application/vnd.ipld.car"},
10
+
"parameters" => %{
11
+
"properties" => %{
12
+
"did" => %{
13
+
"description" => "The DID of the repo.",
14
+
"format" => "did",
15
+
"type" => "string"
16
+
}
17
+
},
18
+
"required" => ["did"],
19
+
"type" => "params"
20
+
},
21
+
"type" => "query"
22
+
}
23
+
},
24
+
"id" => "com.atproto.sync.getCheckout",
25
+
"lexicon" => 1
26
+
})
27
+
end
+35
lib/atproto/com/atproto/sync/getHead.ex
+35
lib/atproto/com/atproto/sync/getHead.ex
···
1
+
defmodule Com.Atproto.Sync.GetHead do
2
+
@moduledoc false
3
+
use Atex.Lexicon
4
+
5
+
deflexicon(%{
6
+
"defs" => %{
7
+
"main" => %{
8
+
"description" => "DEPRECATED - please use com.atproto.sync.getLatestCommit instead",
9
+
"errors" => [%{"name" => "HeadNotFound"}],
10
+
"output" => %{
11
+
"encoding" => "application/json",
12
+
"schema" => %{
13
+
"properties" => %{"root" => %{"format" => "cid", "type" => "string"}},
14
+
"required" => ["root"],
15
+
"type" => "object"
16
+
}
17
+
},
18
+
"parameters" => %{
19
+
"properties" => %{
20
+
"did" => %{
21
+
"description" => "The DID of the repo.",
22
+
"format" => "did",
23
+
"type" => "string"
24
+
}
25
+
},
26
+
"required" => ["did"],
27
+
"type" => "params"
28
+
},
29
+
"type" => "query"
30
+
}
31
+
},
32
+
"id" => "com.atproto.sync.getHead",
33
+
"lexicon" => 1
34
+
})
35
+
end
+51
lib/atproto/com/atproto/sync/getHostStatus.ex
+51
lib/atproto/com/atproto/sync/getHostStatus.ex
···
1
+
defmodule Com.Atproto.Sync.GetHostStatus do
2
+
@moduledoc false
3
+
use Atex.Lexicon
4
+
5
+
deflexicon(%{
6
+
"defs" => %{
7
+
"main" => %{
8
+
"description" =>
9
+
"Returns information about a specified upstream host, as consumed by the server. Implemented by relays.",
10
+
"errors" => [%{"name" => "HostNotFound"}],
11
+
"output" => %{
12
+
"encoding" => "application/json",
13
+
"schema" => %{
14
+
"properties" => %{
15
+
"accountCount" => %{
16
+
"description" =>
17
+
"Number of accounts on the server which are associated with the upstream host. Note that the upstream may actually have more accounts.",
18
+
"type" => "integer"
19
+
},
20
+
"hostname" => %{"type" => "string"},
21
+
"seq" => %{
22
+
"description" =>
23
+
"Recent repo stream event sequence number. May be delayed from actual stream processing (eg, persisted cursor not in-memory cursor).",
24
+
"type" => "integer"
25
+
},
26
+
"status" => %{
27
+
"ref" => "com.atproto.sync.defs#hostStatus",
28
+
"type" => "ref"
29
+
}
30
+
},
31
+
"required" => ["hostname"],
32
+
"type" => "object"
33
+
}
34
+
},
35
+
"parameters" => %{
36
+
"properties" => %{
37
+
"hostname" => %{
38
+
"description" => "Hostname of the host (eg, PDS or relay) being queried.",
39
+
"type" => "string"
40
+
}
41
+
},
42
+
"required" => ["hostname"],
43
+
"type" => "params"
44
+
},
45
+
"type" => "query"
46
+
}
47
+
},
48
+
"id" => "com.atproto.sync.getHostStatus",
49
+
"lexicon" => 1
50
+
})
51
+
end
+44
lib/atproto/com/atproto/sync/getLatestCommit.ex
+44
lib/atproto/com/atproto/sync/getLatestCommit.ex
···
1
+
defmodule Com.Atproto.Sync.GetLatestCommit do
2
+
@moduledoc false
3
+
use Atex.Lexicon
4
+
5
+
deflexicon(%{
6
+
"defs" => %{
7
+
"main" => %{
8
+
"description" =>
9
+
"Get the current commit CID & revision of the specified repo. Does not require auth.",
10
+
"errors" => [
11
+
%{"name" => "RepoNotFound"},
12
+
%{"name" => "RepoTakendown"},
13
+
%{"name" => "RepoSuspended"},
14
+
%{"name" => "RepoDeactivated"}
15
+
],
16
+
"output" => %{
17
+
"encoding" => "application/json",
18
+
"schema" => %{
19
+
"properties" => %{
20
+
"cid" => %{"format" => "cid", "type" => "string"},
21
+
"rev" => %{"format" => "tid", "type" => "string"}
22
+
},
23
+
"required" => ["cid", "rev"],
24
+
"type" => "object"
25
+
}
26
+
},
27
+
"parameters" => %{
28
+
"properties" => %{
29
+
"did" => %{
30
+
"description" => "The DID of the repo.",
31
+
"format" => "did",
32
+
"type" => "string"
33
+
}
34
+
},
35
+
"required" => ["did"],
36
+
"type" => "params"
37
+
},
38
+
"type" => "query"
39
+
}
40
+
},
41
+
"id" => "com.atproto.sync.getLatestCommit",
42
+
"lexicon" => 1
43
+
})
44
+
end
+41
lib/atproto/com/atproto/sync/getRecord.ex
+41
lib/atproto/com/atproto/sync/getRecord.ex
···
1
+
defmodule Com.Atproto.Sync.GetRecord do
2
+
@moduledoc false
3
+
use Atex.Lexicon
4
+
5
+
deflexicon(%{
6
+
"defs" => %{
7
+
"main" => %{
8
+
"description" =>
9
+
"Get data blocks needed to prove the existence or non-existence of record in the current version of repo. Does not require auth.",
10
+
"errors" => [
11
+
%{"name" => "RecordNotFound"},
12
+
%{"name" => "RepoNotFound"},
13
+
%{"name" => "RepoTakendown"},
14
+
%{"name" => "RepoSuspended"},
15
+
%{"name" => "RepoDeactivated"}
16
+
],
17
+
"output" => %{"encoding" => "application/vnd.ipld.car"},
18
+
"parameters" => %{
19
+
"properties" => %{
20
+
"collection" => %{"format" => "nsid", "type" => "string"},
21
+
"did" => %{
22
+
"description" => "The DID of the repo.",
23
+
"format" => "did",
24
+
"type" => "string"
25
+
},
26
+
"rkey" => %{
27
+
"description" => "Record Key",
28
+
"format" => "record-key",
29
+
"type" => "string"
30
+
}
31
+
},
32
+
"required" => ["did", "collection", "rkey"],
33
+
"type" => "params"
34
+
},
35
+
"type" => "query"
36
+
}
37
+
},
38
+
"id" => "com.atproto.sync.getRecord",
39
+
"lexicon" => 1
40
+
})
41
+
end
+39
lib/atproto/com/atproto/sync/getRepo.ex
+39
lib/atproto/com/atproto/sync/getRepo.ex
···
1
+
defmodule Com.Atproto.Sync.GetRepo do
2
+
@moduledoc false
3
+
use Atex.Lexicon
4
+
5
+
deflexicon(%{
6
+
"defs" => %{
7
+
"main" => %{
8
+
"description" =>
9
+
"Download a repository export as CAR file. Optionally only a 'diff' since a previous revision. Does not require auth; implemented by PDS.",
10
+
"errors" => [
11
+
%{"name" => "RepoNotFound"},
12
+
%{"name" => "RepoTakendown"},
13
+
%{"name" => "RepoSuspended"},
14
+
%{"name" => "RepoDeactivated"}
15
+
],
16
+
"output" => %{"encoding" => "application/vnd.ipld.car"},
17
+
"parameters" => %{
18
+
"properties" => %{
19
+
"did" => %{
20
+
"description" => "The DID of the repo.",
21
+
"format" => "did",
22
+
"type" => "string"
23
+
},
24
+
"since" => %{
25
+
"description" => "The revision ('rev') of the repo to create a diff from.",
26
+
"format" => "tid",
27
+
"type" => "string"
28
+
}
29
+
},
30
+
"required" => ["did"],
31
+
"type" => "params"
32
+
},
33
+
"type" => "query"
34
+
}
35
+
},
36
+
"id" => "com.atproto.sync.getRepo",
37
+
"lexicon" => 1
38
+
})
39
+
end
+57
lib/atproto/com/atproto/sync/getRepoStatus.ex
+57
lib/atproto/com/atproto/sync/getRepoStatus.ex
···
1
+
defmodule Com.Atproto.Sync.GetRepoStatus do
2
+
@moduledoc false
3
+
use Atex.Lexicon
4
+
5
+
deflexicon(%{
6
+
"defs" => %{
7
+
"main" => %{
8
+
"description" =>
9
+
"Get the hosting status for a repository, on this server. Expected to be implemented by PDS and Relay.",
10
+
"errors" => [%{"name" => "RepoNotFound"}],
11
+
"output" => %{
12
+
"encoding" => "application/json",
13
+
"schema" => %{
14
+
"properties" => %{
15
+
"active" => %{"type" => "boolean"},
16
+
"did" => %{"format" => "did", "type" => "string"},
17
+
"rev" => %{
18
+
"description" => "Optional field, the current rev of the repo, if active=true",
19
+
"format" => "tid",
20
+
"type" => "string"
21
+
},
22
+
"status" => %{
23
+
"description" =>
24
+
"If active=false, this optional field indicates a possible reason for why the account is not active. If active=false and no status is supplied, then the host makes no claim for why the repository is no longer being hosted.",
25
+
"knownValues" => [
26
+
"takendown",
27
+
"suspended",
28
+
"deleted",
29
+
"deactivated",
30
+
"desynchronized",
31
+
"throttled"
32
+
],
33
+
"type" => "string"
34
+
}
35
+
},
36
+
"required" => ["did", "active"],
37
+
"type" => "object"
38
+
}
39
+
},
40
+
"parameters" => %{
41
+
"properties" => %{
42
+
"did" => %{
43
+
"description" => "The DID of the repo.",
44
+
"format" => "did",
45
+
"type" => "string"
46
+
}
47
+
},
48
+
"required" => ["did"],
49
+
"type" => "params"
50
+
},
51
+
"type" => "query"
52
+
}
53
+
},
54
+
"id" => "com.atproto.sync.getRepoStatus",
55
+
"lexicon" => 1
56
+
})
57
+
end
+59
lib/atproto/com/atproto/sync/listBlobs.ex
+59
lib/atproto/com/atproto/sync/listBlobs.ex
···
1
+
defmodule Com.Atproto.Sync.ListBlobs do
2
+
@moduledoc false
3
+
use Atex.Lexicon
4
+
5
+
deflexicon(%{
6
+
"defs" => %{
7
+
"main" => %{
8
+
"description" =>
9
+
"List blob CIDs for an account, since some repo revision. Does not require auth; implemented by PDS.",
10
+
"errors" => [
11
+
%{"name" => "RepoNotFound"},
12
+
%{"name" => "RepoTakendown"},
13
+
%{"name" => "RepoSuspended"},
14
+
%{"name" => "RepoDeactivated"}
15
+
],
16
+
"output" => %{
17
+
"encoding" => "application/json",
18
+
"schema" => %{
19
+
"properties" => %{
20
+
"cids" => %{
21
+
"items" => %{"format" => "cid", "type" => "string"},
22
+
"type" => "array"
23
+
},
24
+
"cursor" => %{"type" => "string"}
25
+
},
26
+
"required" => ["cids"],
27
+
"type" => "object"
28
+
}
29
+
},
30
+
"parameters" => %{
31
+
"properties" => %{
32
+
"cursor" => %{"type" => "string"},
33
+
"did" => %{
34
+
"description" => "The DID of the repo.",
35
+
"format" => "did",
36
+
"type" => "string"
37
+
},
38
+
"limit" => %{
39
+
"default" => 500,
40
+
"maximum" => 1000,
41
+
"minimum" => 1,
42
+
"type" => "integer"
43
+
},
44
+
"since" => %{
45
+
"description" => "Optional revision of the repo to list blobs since.",
46
+
"format" => "tid",
47
+
"type" => "string"
48
+
}
49
+
},
50
+
"required" => ["did"],
51
+
"type" => "params"
52
+
},
53
+
"type" => "query"
54
+
}
55
+
},
56
+
"id" => "com.atproto.sync.listBlobs",
57
+
"lexicon" => 1
58
+
})
59
+
end
+64
lib/atproto/com/atproto/sync/listHosts.ex
+64
lib/atproto/com/atproto/sync/listHosts.ex
···
1
+
defmodule Com.Atproto.Sync.ListHosts do
2
+
@moduledoc false
3
+
use Atex.Lexicon
4
+
5
+
deflexicon(%{
6
+
"defs" => %{
7
+
"host" => %{
8
+
"properties" => %{
9
+
"accountCount" => %{"type" => "integer"},
10
+
"hostname" => %{
11
+
"description" => "hostname of server; not a URL (no scheme)",
12
+
"type" => "string"
13
+
},
14
+
"seq" => %{
15
+
"description" =>
16
+
"Recent repo stream event sequence number. May be delayed from actual stream processing (eg, persisted cursor not in-memory cursor).",
17
+
"type" => "integer"
18
+
},
19
+
"status" => %{
20
+
"ref" => "com.atproto.sync.defs#hostStatus",
21
+
"type" => "ref"
22
+
}
23
+
},
24
+
"required" => ["hostname"],
25
+
"type" => "object"
26
+
},
27
+
"main" => %{
28
+
"description" =>
29
+
"Enumerates upstream hosts (eg, PDS or relay instances) that this service consumes from. Implemented by relays.",
30
+
"output" => %{
31
+
"encoding" => "application/json",
32
+
"schema" => %{
33
+
"properties" => %{
34
+
"cursor" => %{"type" => "string"},
35
+
"hosts" => %{
36
+
"description" =>
37
+
"Sort order is not formally specified. Recommended order is by time host was first seen by the server, with oldest first.",
38
+
"items" => %{"ref" => "#host", "type" => "ref"},
39
+
"type" => "array"
40
+
}
41
+
},
42
+
"required" => ["hosts"],
43
+
"type" => "object"
44
+
}
45
+
},
46
+
"parameters" => %{
47
+
"properties" => %{
48
+
"cursor" => %{"type" => "string"},
49
+
"limit" => %{
50
+
"default" => 200,
51
+
"maximum" => 1000,
52
+
"minimum" => 1,
53
+
"type" => "integer"
54
+
}
55
+
},
56
+
"type" => "params"
57
+
},
58
+
"type" => "query"
59
+
}
60
+
},
61
+
"id" => "com.atproto.sync.listHosts",
62
+
"lexicon" => 1
63
+
})
64
+
end
+69
lib/atproto/com/atproto/sync/listRepos.ex
+69
lib/atproto/com/atproto/sync/listRepos.ex
···
1
+
defmodule Com.Atproto.Sync.ListRepos do
2
+
@moduledoc false
3
+
use Atex.Lexicon
4
+
5
+
deflexicon(%{
6
+
"defs" => %{
7
+
"main" => %{
8
+
"description" =>
9
+
"Enumerates all the DID, rev, and commit CID for all repos hosted by this service. Does not require auth; implemented by PDS and Relay.",
10
+
"output" => %{
11
+
"encoding" => "application/json",
12
+
"schema" => %{
13
+
"properties" => %{
14
+
"cursor" => %{"type" => "string"},
15
+
"repos" => %{
16
+
"items" => %{"ref" => "#repo", "type" => "ref"},
17
+
"type" => "array"
18
+
}
19
+
},
20
+
"required" => ["repos"],
21
+
"type" => "object"
22
+
}
23
+
},
24
+
"parameters" => %{
25
+
"properties" => %{
26
+
"cursor" => %{"type" => "string"},
27
+
"limit" => %{
28
+
"default" => 500,
29
+
"maximum" => 1000,
30
+
"minimum" => 1,
31
+
"type" => "integer"
32
+
}
33
+
},
34
+
"type" => "params"
35
+
},
36
+
"type" => "query"
37
+
},
38
+
"repo" => %{
39
+
"properties" => %{
40
+
"active" => %{"type" => "boolean"},
41
+
"did" => %{"format" => "did", "type" => "string"},
42
+
"head" => %{
43
+
"description" => "Current repo commit CID",
44
+
"format" => "cid",
45
+
"type" => "string"
46
+
},
47
+
"rev" => %{"format" => "tid", "type" => "string"},
48
+
"status" => %{
49
+
"description" =>
50
+
"If active=false, this optional field indicates a possible reason for why the account is not active. If active=false and no status is supplied, then the host makes no claim for why the repository is no longer being hosted.",
51
+
"knownValues" => [
52
+
"takendown",
53
+
"suspended",
54
+
"deleted",
55
+
"deactivated",
56
+
"desynchronized",
57
+
"throttled"
58
+
],
59
+
"type" => "string"
60
+
}
61
+
},
62
+
"required" => ["did", "head", "rev"],
63
+
"type" => "object"
64
+
}
65
+
},
66
+
"id" => "com.atproto.sync.listRepos",
67
+
"lexicon" => 1
68
+
})
69
+
end
+51
lib/atproto/com/atproto/sync/listReposByCollection.ex
+51
lib/atproto/com/atproto/sync/listReposByCollection.ex
···
1
+
defmodule Com.Atproto.Sync.ListReposByCollection do
2
+
@moduledoc false
3
+
use Atex.Lexicon
4
+
5
+
deflexicon(%{
6
+
"defs" => %{
7
+
"main" => %{
8
+
"description" =>
9
+
"Enumerates all the DIDs which have records with the given collection NSID.",
10
+
"output" => %{
11
+
"encoding" => "application/json",
12
+
"schema" => %{
13
+
"properties" => %{
14
+
"cursor" => %{"type" => "string"},
15
+
"repos" => %{
16
+
"items" => %{"ref" => "#repo", "type" => "ref"},
17
+
"type" => "array"
18
+
}
19
+
},
20
+
"required" => ["repos"],
21
+
"type" => "object"
22
+
}
23
+
},
24
+
"parameters" => %{
25
+
"properties" => %{
26
+
"collection" => %{"format" => "nsid", "type" => "string"},
27
+
"cursor" => %{"type" => "string"},
28
+
"limit" => %{
29
+
"default" => 500,
30
+
"description" =>
31
+
"Maximum size of response set. Recommend setting a large maximum (1000+) when enumerating large DID lists.",
32
+
"maximum" => 2000,
33
+
"minimum" => 1,
34
+
"type" => "integer"
35
+
}
36
+
},
37
+
"required" => ["collection"],
38
+
"type" => "params"
39
+
},
40
+
"type" => "query"
41
+
},
42
+
"repo" => %{
43
+
"properties" => %{"did" => %{"format" => "did", "type" => "string"}},
44
+
"required" => ["did"],
45
+
"type" => "object"
46
+
}
47
+
},
48
+
"id" => "com.atproto.sync.listReposByCollection",
49
+
"lexicon" => 1
50
+
})
51
+
end
+30
lib/atproto/com/atproto/sync/notifyOfUpdate.ex
+30
lib/atproto/com/atproto/sync/notifyOfUpdate.ex
···
1
+
defmodule Com.Atproto.Sync.NotifyOfUpdate do
2
+
@moduledoc false
3
+
use Atex.Lexicon
4
+
5
+
deflexicon(%{
6
+
"defs" => %{
7
+
"main" => %{
8
+
"description" =>
9
+
"Notify a crawling service of a recent update, and that crawling should resume. Intended use is after a gap between repo stream events caused the crawling service to disconnect. Does not require auth; implemented by Relay. DEPRECATED: just use com.atproto.sync.requestCrawl",
10
+
"input" => %{
11
+
"encoding" => "application/json",
12
+
"schema" => %{
13
+
"properties" => %{
14
+
"hostname" => %{
15
+
"description" =>
16
+
"Hostname of the current service (usually a PDS) that is notifying of update.",
17
+
"type" => "string"
18
+
}
19
+
},
20
+
"required" => ["hostname"],
21
+
"type" => "object"
22
+
}
23
+
},
24
+
"type" => "procedure"
25
+
}
26
+
},
27
+
"id" => "com.atproto.sync.notifyOfUpdate",
28
+
"lexicon" => 1
29
+
})
30
+
end
+31
lib/atproto/com/atproto/sync/requestCrawl.ex
+31
lib/atproto/com/atproto/sync/requestCrawl.ex
···
1
+
defmodule Com.Atproto.Sync.RequestCrawl do
2
+
@moduledoc false
3
+
use Atex.Lexicon
4
+
5
+
deflexicon(%{
6
+
"defs" => %{
7
+
"main" => %{
8
+
"description" =>
9
+
"Request a service to persistently crawl hosted repos. Expected use is new PDS instances declaring their existence to Relays. Does not require auth.",
10
+
"errors" => [%{"name" => "HostBanned"}],
11
+
"input" => %{
12
+
"encoding" => "application/json",
13
+
"schema" => %{
14
+
"properties" => %{
15
+
"hostname" => %{
16
+
"description" =>
17
+
"Hostname of the current service (eg, PDS) that is requesting to be crawled.",
18
+
"type" => "string"
19
+
}
20
+
},
21
+
"required" => ["hostname"],
22
+
"type" => "object"
23
+
}
24
+
},
25
+
"type" => "procedure"
26
+
}
27
+
},
28
+
"id" => "com.atproto.sync.requestCrawl",
29
+
"lexicon" => 1
30
+
})
31
+
end
+238
lib/atproto/com/atproto/sync/subscribeRepos.ex
+238
lib/atproto/com/atproto/sync/subscribeRepos.ex
···
1
+
defmodule Com.Atproto.Sync.SubscribeRepos do
2
+
@moduledoc false
3
+
use Atex.Lexicon
4
+
5
+
deflexicon(%{
6
+
"defs" => %{
7
+
"account" => %{
8
+
"description" =>
9
+
"Represents a change to an account's status on a host (eg, PDS or Relay). The semantics of this event are that the status is at the host which emitted the event, not necessarily that at the currently active PDS. Eg, a Relay takedown would emit a takedown with active=false, even if the PDS is still active.",
10
+
"properties" => %{
11
+
"active" => %{
12
+
"description" =>
13
+
"Indicates that the account has a repository which can be fetched from the host that emitted this event.",
14
+
"type" => "boolean"
15
+
},
16
+
"did" => %{"format" => "did", "type" => "string"},
17
+
"seq" => %{"type" => "integer"},
18
+
"status" => %{
19
+
"description" =>
20
+
"If active=false, this optional field indicates a reason for why the account is not active.",
21
+
"knownValues" => [
22
+
"takendown",
23
+
"suspended",
24
+
"deleted",
25
+
"deactivated",
26
+
"desynchronized",
27
+
"throttled"
28
+
],
29
+
"type" => "string"
30
+
},
31
+
"time" => %{"format" => "datetime", "type" => "string"}
32
+
},
33
+
"required" => ["seq", "did", "time", "active"],
34
+
"type" => "object"
35
+
},
36
+
"commit" => %{
37
+
"description" =>
38
+
"Represents an update of repository state. Note that empty commits are allowed, which include no repo data changes, but an update to rev and signature.",
39
+
"nullable" => ["since"],
40
+
"properties" => %{
41
+
"blobs" => %{
42
+
"items" => %{
43
+
"description" =>
44
+
"DEPRECATED -- will soon always be empty. List of new blobs (by CID) referenced by records in this commit.",
45
+
"type" => "cid-link"
46
+
},
47
+
"type" => "array"
48
+
},
49
+
"blocks" => %{
50
+
"description" =>
51
+
"CAR file containing relevant blocks, as a diff since the previous repo state. The commit must be included as a block, and the commit block CID must be the first entry in the CAR header 'roots' list.",
52
+
"maxLength" => 2_000_000,
53
+
"type" => "bytes"
54
+
},
55
+
"commit" => %{
56
+
"description" => "Repo commit object CID.",
57
+
"type" => "cid-link"
58
+
},
59
+
"ops" => %{
60
+
"items" => %{
61
+
"description" =>
62
+
"List of repo mutation operations in this commit (eg, records created, updated, or deleted).",
63
+
"ref" => "#repoOp",
64
+
"type" => "ref"
65
+
},
66
+
"maxLength" => 200,
67
+
"type" => "array"
68
+
},
69
+
"prevData" => %{
70
+
"description" =>
71
+
"The root CID of the MST tree for the previous commit from this repo (indicated by the 'since' revision field in this message). Corresponds to the 'data' field in the repo commit object. NOTE: this field is effectively required for the 'inductive' version of firehose.",
72
+
"type" => "cid-link"
73
+
},
74
+
"rebase" => %{
75
+
"description" => "DEPRECATED -- unused",
76
+
"type" => "boolean"
77
+
},
78
+
"repo" => %{
79
+
"description" =>
80
+
"The repo this event comes from. Note that all other message types name this field 'did'.",
81
+
"format" => "did",
82
+
"type" => "string"
83
+
},
84
+
"rev" => %{
85
+
"description" =>
86
+
"The rev of the emitted commit. Note that this information is also in the commit object included in blocks, unless this is a tooBig event.",
87
+
"format" => "tid",
88
+
"type" => "string"
89
+
},
90
+
"seq" => %{
91
+
"description" => "The stream sequence number of this message.",
92
+
"type" => "integer"
93
+
},
94
+
"since" => %{
95
+
"description" => "The rev of the last emitted commit from this repo (if any).",
96
+
"format" => "tid",
97
+
"type" => "string"
98
+
},
99
+
"time" => %{
100
+
"description" => "Timestamp of when this message was originally broadcast.",
101
+
"format" => "datetime",
102
+
"type" => "string"
103
+
},
104
+
"tooBig" => %{
105
+
"description" =>
106
+
"DEPRECATED -- replaced by #sync event and data limits. Indicates that this commit contained too many ops, or data size was too large. Consumers will need to make a separate request to get missing data.",
107
+
"type" => "boolean"
108
+
}
109
+
},
110
+
"required" => [
111
+
"seq",
112
+
"rebase",
113
+
"tooBig",
114
+
"repo",
115
+
"commit",
116
+
"rev",
117
+
"since",
118
+
"blocks",
119
+
"ops",
120
+
"blobs",
121
+
"time"
122
+
],
123
+
"type" => "object"
124
+
},
125
+
"identity" => %{
126
+
"description" =>
127
+
"Represents a change to an account's identity. Could be an updated handle, signing key, or pds hosting endpoint. Serves as a prod to all downstream services to refresh their identity cache.",
128
+
"properties" => %{
129
+
"did" => %{"format" => "did", "type" => "string"},
130
+
"handle" => %{
131
+
"description" =>
132
+
"The current handle for the account, or 'handle.invalid' if validation fails. This field is optional, might have been validated or passed-through from an upstream source. Semantics and behaviors for PDS vs Relay may evolve in the future; see atproto specs for more details.",
133
+
"format" => "handle",
134
+
"type" => "string"
135
+
},
136
+
"seq" => %{"type" => "integer"},
137
+
"time" => %{"format" => "datetime", "type" => "string"}
138
+
},
139
+
"required" => ["seq", "did", "time"],
140
+
"type" => "object"
141
+
},
142
+
"info" => %{
143
+
"properties" => %{
144
+
"message" => %{"type" => "string"},
145
+
"name" => %{"knownValues" => ["OutdatedCursor"], "type" => "string"}
146
+
},
147
+
"required" => ["name"],
148
+
"type" => "object"
149
+
},
150
+
"main" => %{
151
+
"description" =>
152
+
"Repository event stream, aka Firehose endpoint. Outputs repo commits with diff data, and identity update events, for all repositories on the current server. See the atproto specifications for details around stream sequencing, repo versioning, CAR diff format, and more. Public and does not require auth; implemented by PDS and Relay.",
153
+
"errors" => [
154
+
%{"name" => "FutureCursor"},
155
+
%{
156
+
"description" =>
157
+
"If the consumer of the stream can not keep up with events, and a backlog gets too large, the server will drop the connection.",
158
+
"name" => "ConsumerTooSlow"
159
+
}
160
+
],
161
+
"message" => %{
162
+
"schema" => %{
163
+
"refs" => ["#commit", "#sync", "#identity", "#account", "#info"],
164
+
"type" => "union"
165
+
}
166
+
},
167
+
"parameters" => %{
168
+
"properties" => %{
169
+
"cursor" => %{
170
+
"description" => "The last known event seq number to backfill from.",
171
+
"type" => "integer"
172
+
}
173
+
},
174
+
"type" => "params"
175
+
},
176
+
"type" => "subscription"
177
+
},
178
+
"repoOp" => %{
179
+
"description" => "A repo operation, ie a mutation of a single record.",
180
+
"nullable" => ["cid"],
181
+
"properties" => %{
182
+
"action" => %{
183
+
"knownValues" => ["create", "update", "delete"],
184
+
"type" => "string"
185
+
},
186
+
"cid" => %{
187
+
"description" => "For creates and updates, the new record CID. For deletions, null.",
188
+
"type" => "cid-link"
189
+
},
190
+
"path" => %{"type" => "string"},
191
+
"prev" => %{
192
+
"description" =>
193
+
"For updates and deletes, the previous record CID (required for inductive firehose). For creations, field should not be defined.",
194
+
"type" => "cid-link"
195
+
}
196
+
},
197
+
"required" => ["action", "path", "cid"],
198
+
"type" => "object"
199
+
},
200
+
"sync" => %{
201
+
"description" =>
202
+
"Updates the repo to a new state, without necessarily including that state on the firehose. Used to recover from broken commit streams, data loss incidents, or in situations where upstream host does not know recent state of the repository.",
203
+
"properties" => %{
204
+
"blocks" => %{
205
+
"description" =>
206
+
"CAR file containing the commit, as a block. The CAR header must include the commit block CID as the first 'root'.",
207
+
"maxLength" => 10000,
208
+
"type" => "bytes"
209
+
},
210
+
"did" => %{
211
+
"description" =>
212
+
"The account this repo event corresponds to. Must match that in the commit object.",
213
+
"format" => "did",
214
+
"type" => "string"
215
+
},
216
+
"rev" => %{
217
+
"description" =>
218
+
"The rev of the commit. This value must match that in the commit object.",
219
+
"type" => "string"
220
+
},
221
+
"seq" => %{
222
+
"description" => "The stream sequence number of this message.",
223
+
"type" => "integer"
224
+
},
225
+
"time" => %{
226
+
"description" => "Timestamp of when this message was originally broadcast.",
227
+
"format" => "datetime",
228
+
"type" => "string"
229
+
}
230
+
},
231
+
"required" => ["seq", "did", "blocks", "rev", "time"],
232
+
"type" => "object"
233
+
}
234
+
},
235
+
"id" => "com.atproto.sync.subscribeRepos",
236
+
"lexicon" => 1
237
+
})
238
+
end
-159
lib/aturi.ex
-159
lib/aturi.ex
···
1
-
defmodule Atex.AtURI do
2
-
@moduledoc """
3
-
Struct and helper functions for manipulating `at://` URIs, which identify
4
-
specific records within the AT Protocol.
5
-
6
-
ATProto spec: https://atproto.com/specs/at-uri-scheme
7
-
8
-
This module only supports the restricted URI syntax used for the Lexicon
9
-
`at-uri` type, with no support for query strings or fragments. If/when the
10
-
full syntax gets widespread use, this module will expand to accomodate them.
11
-
12
-
Both URIs using DIDs and handles ("example.com") are supported.
13
-
"""
14
-
15
-
use TypedStruct
16
-
17
-
@did ~S"did:(?:plc|web):[a-zA-Z0-9._:%-]*[a-zA-Z0-9._-]"
18
-
@handle ~S"(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?"
19
-
@nsid ~S"[a-zA-Z](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?:\.[a-zA-Z](?:[a-zA-Z0-9]{0,62})?)"
20
-
21
-
@authority "(?<authority>(?:#{@did})|(?:#{@handle}))"
22
-
@collection "(?<collection>#{@nsid})"
23
-
@rkey "(?<rkey>[a-zA-Z0-9.-_:~]{1,512})"
24
-
25
-
@re ~r"^at://#{@authority}(?:/#{@collection}(?:/#{@rkey})?)?$"
26
-
27
-
typedstruct do
28
-
field :authority, String.t(), enforce: true
29
-
field :collection, String.t() | nil
30
-
field :rkey, String.t() | nil
31
-
end
32
-
33
-
@doc """
34
-
Create a new AtURI struct from a string by matching it against the regex.
35
-
36
-
Returns `{:ok, aturi}` if a valid `at://` URI is given, otherwise it will return `:error`.
37
-
38
-
## Examples
39
-
40
-
iex> Atex.AtURI.new("at://did:plc:44ybard66vv44zksje25o7dz/app.bsky.feed.post/3jwdwj2ctlk26")
41
-
{:ok, %Atex.AtURI{
42
-
rkey: "3jwdwj2ctlk26",
43
-
collection: "app.bsky.feed.post",
44
-
authority: "did:plc:44ybard66vv44zksje25o7dz"
45
-
}}
46
-
47
-
iex> Atex.AtURI.new("at:invalid/malformed")
48
-
:error
49
-
50
-
Partial URIs pointing to a collection without a record key, or even just a given authority, are also supported:
51
-
52
-
iex> Atex.AtURI.new("at://ovyerus.com/sh.comet.v0.feed.track")
53
-
{:ok, %Atex.AtURI{
54
-
rkey: nil,
55
-
collection: "sh.comet.v0.feed.track",
56
-
authority: "ovyerus.com"
57
-
}}
58
-
59
-
iex> Atex.AtURI.new("at://did:web:comet.sh")
60
-
{:ok, %Atex.AtURI{
61
-
rkey: nil,
62
-
collection: nil,
63
-
authority: "did:web:comet.sh"
64
-
}}
65
-
"""
66
-
@spec new(String.t()) :: {:ok, t()} | :error
67
-
def new(string) when is_binary(string) do
68
-
# TODO: test different ways to get a good error from regex on which part failed match?
69
-
case Regex.named_captures(@re, string) do
70
-
%{} = captures -> {:ok, from_named_captures(captures)}
71
-
nil -> :error
72
-
end
73
-
end
74
-
75
-
@doc """
76
-
The same as `new/1` but raises an `ArgumentError` if an invalid string is given.
77
-
78
-
## Examples
79
-
80
-
iex> Atex.AtURI.new!("at://did:plc:44ybard66vv44zksje25o7dz/app.bsky.feed.post/3jwdwj2ctlk26")
81
-
%Atex.AtURI{
82
-
rkey: "3jwdwj2ctlk26",
83
-
collection: "app.bsky.feed.post",
84
-
authority: "did:plc:44ybard66vv44zksje25o7dz"
85
-
}
86
-
87
-
iex> Atex.AtURI.new!("at:invalid/malformed")
88
-
** (ArgumentError) Malformed at:// URI
89
-
"""
90
-
@spec new!(String.t()) :: t()
91
-
def new!(string) when is_binary(string) do
92
-
case new(string) do
93
-
{:ok, uri} -> uri
94
-
:error -> raise ArgumentError, message: "Malformed at:// URI"
95
-
end
96
-
end
97
-
98
-
@doc """
99
-
Check if a string is a valid `at://` URI.
100
-
101
-
## Examples
102
-
103
-
iex> Atex.AtURI.match?("at://did:plc:44ybard66vv44zksje25o7dz/app.bsky.feed.post/3jwdwj2ctlk26")
104
-
true
105
-
106
-
iex> Atex.AtURI.match?("at://did:web:comet.sh")
107
-
true
108
-
109
-
iex> Atex.AtURI.match?("at://ovyerus.com/sh.comet.v0.feed.track")
110
-
true
111
-
112
-
iex> Atex.AtURI.match?("gobbledy gook")
113
-
false
114
-
"""
115
-
@spec match?(String.t()) :: boolean()
116
-
def match?(string), do: Regex.match?(@re, string)
117
-
118
-
@doc """
119
-
Format an `Atex.AtURI` to the canonical string representation.
120
-
121
-
Also available via the `String.Chars` protocol.
122
-
123
-
## Examples
124
-
125
-
iex> aturi = %Atex.AtURI{
126
-
...> rkey: "3jwdwj2ctlk26",
127
-
...> collection: "app.bsky.feed.post",
128
-
...> authority: "did:plc:44ybard66vv44zksje25o7dz"
129
-
...> }
130
-
iex> Atex.AtURI.to_string(aturi)
131
-
"at://did:plc:44ybard66vv44zksje25o7dz/app.bsky.feed.post/3jwdwj2ctlk26"
132
-
133
-
iex> aturi = %Atex.AtURI{authority: "did:web:comet.sh"}
134
-
iex> to_string(aturi)
135
-
"at://did:web:comet.sh"
136
-
"""
137
-
@spec to_string(t()) :: String.t()
138
-
def to_string(%__MODULE__{} = uri) do
139
-
"at://#{uri.authority}/#{uri.collection}/#{uri.rkey}"
140
-
|> String.trim_trailing("/")
141
-
end
142
-
143
-
defp from_named_captures(%{"authority" => authority, "collection" => "", "rkey" => ""}),
144
-
do: %__MODULE__{authority: authority}
145
-
146
-
defp from_named_captures(%{"authority" => authority, "collection" => collection, "rkey" => ""}),
147
-
do: %__MODULE__{authority: authority, collection: collection}
148
-
149
-
defp from_named_captures(%{
150
-
"authority" => authority,
151
-
"collection" => collection,
152
-
"rkey" => rkey
153
-
}),
154
-
do: %__MODULE__{authority: authority, collection: collection, rkey: rkey}
155
-
end
156
-
157
-
defimpl String.Chars, for: Atex.AtURI do
158
-
def to_string(uri), do: Atex.AtURI.to_string(uri)
159
-
end
-39
lib/base32_sortable.ex
-39
lib/base32_sortable.ex
···
1
-
defmodule Atex.Base32Sortable do
2
-
@moduledoc """
3
-
Codec for the base32-sortable encoding.
4
-
"""
5
-
6
-
@alphabet ~c(234567abcdefghijklmnopqrstuvwxyz)
7
-
@alphabet_len length(@alphabet)
8
-
9
-
@doc """
10
-
Encode an integer as a base32-sortable string.
11
-
"""
12
-
@spec encode(integer()) :: String.t()
13
-
def encode(int) when is_integer(int), do: do_encode(int, "")
14
-
15
-
@spec do_encode(integer(), String.t()) :: String.t()
16
-
defp do_encode(0, acc), do: acc
17
-
18
-
defp do_encode(int, acc) do
19
-
char_index = rem(int, @alphabet_len)
20
-
new_int = div(int, @alphabet_len)
21
-
22
-
# Chars are prepended to the accumulator because rem/div is pulling them off the tail of the integer.
23
-
do_encode(new_int, <<Enum.at(@alphabet, char_index)>> <> acc)
24
-
end
25
-
26
-
@doc """
27
-
Decode a base32-sortable string to an integer.
28
-
"""
29
-
@spec decode(String.t()) :: integer()
30
-
def decode(str) when is_binary(str), do: do_decode(str, 0)
31
-
32
-
@spec do_decode(String.t(), integer()) :: integer()
33
-
defp do_decode(<<>>, acc), do: acc
34
-
35
-
defp do_decode(<<char::utf8, rest::binary>>, acc) do
36
-
i = Enum.find_index(@alphabet, fn x -> x == char end)
37
-
do_decode(rest, acc * @alphabet_len + i)
38
-
end
39
-
end
+94
lib/mix/tasks/atex.lexicons.ex
+94
lib/mix/tasks/atex.lexicons.ex
···
1
+
defmodule Mix.Tasks.Atex.Lexicons do
2
+
@moduledoc """
3
+
Generate Elixir modules from AT Protocol lexicons, which can then be used to
4
+
validate data at runtime.
5
+
6
+
AT Protocol lexicons are JSON files that define parts of the AT Protocol data
7
+
model. This task processes these lexicon files and generates corresponding
8
+
Elixir modules.
9
+
10
+
## Usage
11
+
12
+
mix atex.lexicons [OPTIONS] [PATHS]
13
+
14
+
## Arguments
15
+
16
+
- `PATHS` - List of lexicon files to process. Also supports standard glob
17
+
syntax for reading many lexicons at once.
18
+
19
+
## Options
20
+
21
+
- `-o`/`--output` - Output directory for generated modules (default:
22
+
`lib/atproto`)
23
+
24
+
## Examples
25
+
26
+
Process all JSON files in the lexicons directory:
27
+
28
+
mix atex.lexicons lexicons/**/*.json
29
+
30
+
Process specific lexicon files:
31
+
32
+
mix atex.lexicons lexicons/com/atproto/repo/*.json lexicons/app/bsky/actor/profile.json
33
+
34
+
Generate modules to a custom output directory:
35
+
36
+
mix atex.lexicons lexicons/**/*.json --output lib/my_atproto
37
+
"""
38
+
@shortdoc "Generate Elixir modules from AT Protocol lexicons."
39
+
40
+
use Mix.Task
41
+
require EEx
42
+
43
+
@switches [output: :string]
44
+
@aliases [o: :output]
45
+
@template_path Path.expand("../../../priv/templates/lexicon.eex", __DIR__)
46
+
47
+
@impl true
48
+
def run(args) do
49
+
{options, globs} = OptionParser.parse!(args, switches: @switches, aliases: @aliases)
50
+
51
+
output = Keyword.get(options, :output, "lib/atproto")
52
+
paths = Enum.flat_map(globs, &Path.wildcard/1)
53
+
54
+
if length(paths) == 0 do
55
+
Mix.shell().error("No valid search paths have been provided, aborting.")
56
+
else
57
+
Mix.shell().info("Generating modules for lexicons into #{output}")
58
+
59
+
Enum.each(paths, fn path ->
60
+
Mix.shell().info("- #{path}")
61
+
generate(path, output)
62
+
end)
63
+
end
64
+
end
65
+
66
+
# TODO: validate schema?
67
+
defp generate(input, output) do
68
+
lexicon =
69
+
input
70
+
|> File.read!()
71
+
|> JSON.decode!()
72
+
73
+
if not is_binary(lexicon["id"]) do
74
+
raise ArgumentError, message: "Malformed lexicon: does not have an `id` field."
75
+
end
76
+
77
+
code = lexicon |> template() |> Code.format_string!() |> Enum.join("")
78
+
79
+
file_path =
80
+
lexicon["id"]
81
+
|> String.split(".")
82
+
|> Enum.join("/")
83
+
|> then(&(&1 <> ".ex"))
84
+
|> then(&Path.join(output, &1))
85
+
86
+
file_path
87
+
|> Path.dirname()
88
+
|> File.mkdir_p!()
89
+
90
+
File.write!(file_path, code)
91
+
end
92
+
93
+
EEx.function_from_file(:defp, :template, @template_path, [:lexicon])
94
+
end
-169
lib/tid.ex
-169
lib/tid.ex
···
1
-
defmodule Atex.TID do
2
-
@moduledoc """
3
-
Struct and helper functions for dealing with AT Protocol TIDs (Timestamp
4
-
Identifiers), a 13-character string representation of a 64-bit number
5
-
comprised of a Unix timestamp (in microsecond precision) and a random "clock
6
-
identifier" to help avoid collisions.
7
-
8
-
ATProto spec: https://atproto.com/specs/tid
9
-
10
-
TID strings are always 13 characters long. All bits in the 64-bit number are
11
-
encoded, essentially meaning that the string is padded with "2" if necessary,
12
-
(the 0th character in the base32-sortable alphabet).
13
-
"""
14
-
import Bitwise
15
-
alias Atex.Base32Sortable
16
-
use TypedStruct
17
-
18
-
@re ~r/^[234567abcdefghij][234567abcdefghijklmnopqrstuvwxyz]{12}$/
19
-
20
-
@typedoc """
21
-
A Unix timestamp representing when the TID was created.
22
-
"""
23
-
@type timestamp() :: integer()
24
-
25
-
@typedoc """
26
-
An integer to be used for the lower 10 bits of the TID.
27
-
"""
28
-
@type clock_id() :: 0..1023
29
-
30
-
typedstruct enforce: true do
31
-
field :timestamp, timestamp()
32
-
field :clock_id, clock_id()
33
-
end
34
-
35
-
@doc """
36
-
Returns a TID for the current moment in time, along with a random clock ID.
37
-
"""
38
-
@spec now() :: t()
39
-
def now,
40
-
do: %__MODULE__{
41
-
timestamp: DateTime.utc_now(:microsecond) |> DateTime.to_unix(:microsecond),
42
-
clock_id: gen_clock_id()
43
-
}
44
-
45
-
@doc """
46
-
Create a new TID from a `DateTime` or an integer representing a Unix timestamp in microseconds.
47
-
48
-
If `clock_id` isn't provided, a random one will be generated.
49
-
"""
50
-
@spec new(DateTime.t() | integer(), integer() | nil) :: t()
51
-
def new(source, clock_id \\ nil)
52
-
53
-
def new(%DateTime{} = datetime, clock_id),
54
-
do: %__MODULE__{
55
-
timestamp: DateTime.to_unix(datetime, :microsecond),
56
-
clock_id: clock_id || gen_clock_id()
57
-
}
58
-
59
-
def new(unix, clock_id) when is_integer(unix),
60
-
do: %__MODULE__{timestamp: unix, clock_id: clock_id || gen_clock_id()}
61
-
62
-
@doc """
63
-
Convert a TID struct to an instance of `DateTime`.
64
-
"""
65
-
def to_datetime(%__MODULE__{} = tid), do: DateTime.from_unix(tid.timestamp, :microsecond)
66
-
67
-
@doc """
68
-
Generate a random integer to be used as a `clock_id`.
69
-
"""
70
-
@spec gen_clock_id() :: clock_id()
71
-
def gen_clock_id, do: :rand.uniform(1024) - 1
72
-
73
-
@doc """
74
-
Decode a TID string into an `Atex.TID` struct, returning an error if it's invalid.
75
-
76
-
## Examples
77
-
78
-
Syntactically valid TIDs:
79
-
80
-
iex> Atex.TID.decode("3jzfcijpj2z2a")
81
-
{:ok, %Atex.TID{clock_id: 6, timestamp: 1688137381887007}}
82
-
83
-
iex> Atex.TID.decode("7777777777777")
84
-
{:ok, %Atex.TID{clock_id: 165, timestamp: 5811096293381285}}
85
-
86
-
iex> Atex.TID.decode("3zzzzzzzzzzzz")
87
-
{:ok, %Atex.TID{clock_id: 1023, timestamp: 2251799813685247}}
88
-
89
-
iex> Atex.TID.decode("2222222222222")
90
-
{:ok, %Atex.TID{clock_id: 0, timestamp: 0}}
91
-
92
-
Invalid TIDs:
93
-
94
-
# not base32
95
-
iex> Atex.TID.decode("3jzfcijpj2z21")
96
-
:error
97
-
iex> Atex.TID.decode("0000000000000")
98
-
:error
99
-
100
-
# case-sensitive
101
-
iex> Atex.TID.decode("3JZFCIJPJ2Z2A")
102
-
:error
103
-
104
-
# too long/short
105
-
iex> Atex.TID.decode("3jzfcijpj2z2aa")
106
-
:error
107
-
iex> Atex.TID.decode("3jzfcijpj2z2")
108
-
:error
109
-
iex> Atex.TID.decode("222")
110
-
:error
111
-
112
-
# legacy dash syntax *not* supported (TTTT-TTT-TTTT-CC)
113
-
iex> Atex.TID.decode("3jzf-cij-pj2z-2a")
114
-
:error
115
-
116
-
# high bit can't be set
117
-
iex> Atex.TID.decode("zzzzzzzzzzzzz")
118
-
:error
119
-
iex> Atex.TID.decode("kjzfcijpj2z2a")
120
-
:error
121
-
122
-
"""
123
-
@spec decode(String.t()) :: {:ok, t()} | :error
124
-
def decode(<<timestamp::binary-size(11), clock_id::binary-size(2)>> = tid) do
125
-
if Regex.match?(@re, tid) do
126
-
timestamp = Base32Sortable.decode(timestamp)
127
-
clock_id = Base32Sortable.decode(clock_id)
128
-
129
-
{:ok,
130
-
%__MODULE__{
131
-
timestamp: timestamp,
132
-
clock_id: clock_id
133
-
}}
134
-
else
135
-
:error
136
-
end
137
-
end
138
-
139
-
def decode(_tid), do: :error
140
-
141
-
@doc """
142
-
Encode a TID struct into a string.
143
-
144
-
## Examples
145
-
146
-
iex> Atex.TID.encode(%Atex.TID{clock_id: 6, timestamp: 1688137381887007})
147
-
"3jzfcijpj2z2a"
148
-
149
-
iex> Atex.TID.encode(%Atex.TID{clock_id: 165, timestamp: 5811096293381285})
150
-
"7777777777777"
151
-
152
-
iex> Atex.TID.encode(%Atex.TID{clock_id: 1023, timestamp: 2251799813685247})
153
-
"3zzzzzzzzzzzz"
154
-
155
-
iex> Atex.TID.encode(%Atex.TID{clock_id: 0, timestamp: 0})
156
-
"2222222222222"
157
-
158
-
"""
159
-
@spec encode(t()) :: String.t()
160
-
def encode(%__MODULE__{} = tid) do
161
-
timestamp = tid.timestamp |> Base32Sortable.encode() |> String.pad_leading(11, "2")
162
-
clock_id = (tid.clock_id &&& 1023) |> Base32Sortable.encode() |> String.pad_leading(2, "2")
163
-
timestamp <> clock_id
164
-
end
165
-
end
166
-
167
-
defimpl String.Chars, for: Atex.TID do
168
-
def to_string(tid), do: Atex.TID.encode(tid)
169
-
end
-27
lib/xrpc/adapter/req.ex
-27
lib/xrpc/adapter/req.ex
···
1
-
defmodule Atex.XRPC.Adapter.Req do
2
-
@moduledoc """
3
-
`Req` adapter for XRPC.
4
-
"""
5
-
6
-
@behaviour Atex.XRPC.Adapter
7
-
8
-
def get(url, opts) do
9
-
Req.get(url, opts) |> adapt()
10
-
end
11
-
12
-
def post(url, opts) do
13
-
Req.post(url, opts) |> adapt()
14
-
end
15
-
16
-
defp adapt({:ok, %Req.Response{status: 200} = res}) do
17
-
{:ok, res.body}
18
-
end
19
-
20
-
defp adapt({:ok, %Req.Response{} = res}) do
21
-
{:error, res.status, res.body}
22
-
end
23
-
24
-
defp adapt({:error, exception}) do
25
-
{:error, exception}
26
-
end
27
-
end
-12
lib/xrpc/adapter.ex
-12
lib/xrpc/adapter.ex
···
1
-
defmodule Atex.XRPC.Adapter do
2
-
@moduledoc """
3
-
Behaviour for defining a HTTP client adapter to be used for XRPC.
4
-
"""
5
-
6
-
@type success() :: {:ok, map()}
7
-
@type error() :: {:error, integer(), map()} | {:error, term()}
8
-
@type result() :: success() | error()
9
-
10
-
@callback get(url :: String.t(), opts :: keyword()) :: result()
11
-
@callback post(url :: String.t(), opts :: keyword()) :: result()
12
-
end
-87
lib/xrpc/client.ex
-87
lib/xrpc/client.ex
···
1
-
defmodule Atex.XRPC.Client do
2
-
@doc """
3
-
Struct to store client information for ATProto XRPC.
4
-
"""
5
-
6
-
alias Atex.XRPC
7
-
use TypedStruct
8
-
9
-
typedstruct do
10
-
field :endpoint, String.t(), enforce: true
11
-
field :access_token, String.t() | nil
12
-
field :refresh_token, String.t() | nil
13
-
end
14
-
15
-
@doc """
16
-
Create a new `Atex.XRPC.Client` from an endpoint, and optionally an
17
-
access/refresh token.
18
-
19
-
Endpoint should be the base URL of a PDS, or an AppView in the case of
20
-
unauthenticated requests (like Bluesky's public API), e.g.
21
-
`https://bsky.social`.
22
-
"""
23
-
@spec new(String.t()) :: t()
24
-
@spec new(String.t(), String.t() | nil) :: t()
25
-
@spec new(String.t(), String.t() | nil, String.t() | nil) :: t()
26
-
def new(endpoint, access_token \\ nil, refresh_token \\ nil) do
27
-
%__MODULE__{endpoint: endpoint, access_token: access_token, refresh_token: refresh_token}
28
-
end
29
-
30
-
@doc """
31
-
Create a new `Atex.XRPC.Client` by logging in with an `identifier` and
32
-
`password` to fetch an initial pair of access & refresh tokens.
33
-
34
-
Uses `com.atproto.server.createSession` under the hood, so `identifier` can be
35
-
either a handle or a DID.
36
-
37
-
## Examples
38
-
39
-
iex> Atex.XRPC.Client.login("https://bsky.social", "example.com", "password123")
40
-
{:ok, %Atex.XRPC.Client{...}}
41
-
"""
42
-
@spec login(String.t(), String.t(), String.t()) :: {:ok, t()} | XRPC.Adapter.error()
43
-
@spec login(String.t(), String.t(), String.t(), String.t() | nil) ::
44
-
{:ok, t()} | XRPC.Adapter.error()
45
-
def login(endpoint, identifier, password, auth_factor_token \\ nil) do
46
-
json =
47
-
%{identifier: identifier, password: password}
48
-
|> then(
49
-
&if auth_factor_token do
50
-
Map.merge(&1, %{authFactorToken: auth_factor_token})
51
-
else
52
-
&1
53
-
end
54
-
)
55
-
56
-
response = XRPC.unauthed_post(endpoint, "com.atproto.server.createSession", json: json)
57
-
58
-
case response do
59
-
{:ok, %{"accessJwt" => access_token, "refreshJwt" => refresh_token}} ->
60
-
{:ok, new(endpoint, access_token, refresh_token)}
61
-
62
-
err ->
63
-
err
64
-
end
65
-
end
66
-
67
-
@doc """
68
-
Request a new `refresh_token` for the given client.
69
-
"""
70
-
@spec refresh(t()) :: {:ok, t()} | XRPC.Adapter.error()
71
-
def refresh(%__MODULE__{endpoint: endpoint, refresh_token: refresh_token} = client) do
72
-
response =
73
-
XRPC.unauthed_post(
74
-
endpoint,
75
-
"com.atproto.server.refreshSession",
76
-
XRPC.put_auth([], refresh_token)
77
-
)
78
-
79
-
case response do
80
-
{:ok, %{"accessJwt" => access_token, "refreshJwt" => refresh_token}} ->
81
-
%{client | access_token: access_token, refresh_token: refresh_token}
82
-
83
-
err ->
84
-
err
85
-
end
86
-
end
87
-
end
-71
lib/xrpc.ex
-71
lib/xrpc.ex
···
1
-
defmodule Atex.XRPC do
2
-
alias Atex.XRPC
3
-
4
-
defp adapter do
5
-
Application.get_env(:atex, :adapter, XRPC.Adapter.Req)
6
-
end
7
-
8
-
# TODO: automatic user-agent, and env for changing it
9
-
10
-
# TODO: consistent struct shape/protocol for Lexicon schemas so that user can pass in
11
-
# an object (hopefully validated by its module) without needing to specify the
12
-
# name & opts separately, and possibly verify the output response against it?
13
-
14
-
# TODO: auto refresh, will need to return a client instance in each method.
15
-
16
-
@doc """
17
-
Perform a HTTP GET on a XRPC resource. Called a "query" in lexicons.
18
-
"""
19
-
@spec get(XRPC.Client.t(), String.t(), keyword()) :: XRPC.Adapter.result()
20
-
def get(%XRPC.Client{} = client, name, opts \\ []) do
21
-
opts = put_auth(opts, client.access_token)
22
-
adapter().get(url(client, name), opts)
23
-
end
24
-
25
-
@doc """
26
-
Perform a HTTP POST on a XRPC resource. Called a "prodecure" in lexicons.
27
-
"""
28
-
@spec post(XRPC.Client.t(), String.t(), keyword()) :: XRPC.Adapter.result()
29
-
def post(%XRPC.Client{} = client, name, opts \\ []) do
30
-
# TODO: look through available HTTP clients and see if they have a
31
-
# consistent way of providing JSON bodies with auto content-type. If not,
32
-
# create one for adapters.
33
-
opts = put_auth(opts, client.access_token)
34
-
adapter().post(url(client, name), opts)
35
-
end
36
-
37
-
@doc """
38
-
Like `get/3` but is unauthenticated by default.
39
-
"""
40
-
@spec unauthed_get(String.t(), String.t(), keyword()) :: XRPC.Adapter.result()
41
-
def unauthed_get(endpoint, name, opts \\ []) do
42
-
adapter().get(url(endpoint, name), opts)
43
-
end
44
-
45
-
@doc """
46
-
Like `post/3` but is unauthenticated by default.
47
-
"""
48
-
@spec unauthed_post(String.t(), String.t(), keyword()) :: XRPC.Adapter.result()
49
-
def unauthed_post(endpoint, name, opts \\ []) do
50
-
adapter().post(url(endpoint, name), opts)
51
-
end
52
-
53
-
# TODO: use URI module for joining instead?
54
-
@spec url(XRPC.Client.t() | String.t(), String.t()) :: String.t()
55
-
defp url(%XRPC.Client{endpoint: endpoint}, name), do: url(endpoint, name)
56
-
defp url(endpoint, name) when is_binary(endpoint), do: "#{endpoint}/xrpc/#{name}"
57
-
58
-
@doc """
59
-
Put an `authorization` header into a keyword list of options to pass to a HTTP client.
60
-
"""
61
-
@spec put_auth(keyword(), String.t()) :: keyword()
62
-
def put_auth(opts, token),
63
-
do: put_headers(opts, authorization: "Bearer #{token}")
64
-
65
-
@spec put_headers(keyword(), keyword()) :: keyword()
66
-
defp put_headers(opts, headers) do
67
-
opts
68
-
|> Keyword.put_new(:headers, [])
69
-
|> Keyword.update(:headers, [], &Keyword.merge(&1, headers))
70
-
end
71
-
end
+28
-8
mix.exs
+28
-8
mix.exs
···
1
1
defmodule Atex.MixProject do
2
2
use Mix.Project
3
3
4
-
@version "0.2.0"
5
-
@source_url "https://github.com/cometsh/atex"
4
+
@version "0.7.0"
5
+
@github "https://github.com/cometsh/atex"
6
+
@tangled "https://tangled.sh/@comet.sh/atex"
6
7
7
8
def project do
8
9
[
···
20
21
21
22
def application do
22
23
[
23
-
extra_applications: [:logger]
24
+
extra_applications: [:logger],
25
+
mod: {Atex.Application, []}
24
26
]
25
27
end
26
28
27
29
defp deps do
28
30
[
31
+
{:peri, "~> 0.6"},
29
32
{:multiformats_ex, "~> 0.2"},
33
+
{:recase, "~> 0.5"},
30
34
{:req, "~> 0.5"},
31
35
{:typedstruct, "~> 0.5"},
36
+
{:ex_cldr, "~> 2.42"},
32
37
{:credo, "~> 1.7", only: [:dev, :test], runtime: false},
33
-
{:ex_doc, "~> 0.34", only: :dev, runtime: false, warn_if_outdated: true}
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
+
{:mutex, "~> 3.0"},
45
+
{:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false}
34
46
]
35
47
end
36
48
37
49
defp package do
38
50
[
39
51
licenses: ["MIT"],
40
-
links: %{"GitHub" => @source_url}
52
+
links: %{"GitHub" => @github, "Tangled" => @tangled}
41
53
]
42
54
end
43
55
···
45
57
[
46
58
extras: [
47
59
LICENSE: [title: "License"],
48
-
"README.md": [title: "Overview"]
60
+
"README.md": [title: "Overview"],
61
+
"CHANGELOG.md": [title: "Changelog"]
49
62
],
50
63
main: "readme",
51
-
source_url: @source_url,
64
+
source_url: @github,
52
65
source_ref: "v#{@version}",
53
-
formatters: ["html"]
66
+
formatters: ["html"],
67
+
groups_for_modules: [
68
+
"Data types": [Atex.AtURI, Atex.DID, Atex.Handle, Atex.NSID, Atex.TID],
69
+
XRPC: ~r/^Atex\.XRPC/,
70
+
OAuth: [Atex.Config.OAuth, Atex.OAuth, Atex.OAuth.Plug],
71
+
Lexicons: ~r/^Atex\.Lexicon/,
72
+
Identity: ~r/^Atex\.IdentityResolver/
73
+
]
54
74
]
55
75
end
56
76
end
+21
-6
mix.lock
+21
-6
mix.lock
···
1
1
%{
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"},
2
3
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
3
-
"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"},
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
+
"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.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
+
"decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"},
8
+
"dialyxir": {:hex, :dialyxir, "1.4.7", "dda948fcee52962e4b6c5b4b16b2d8fa7d50d8645bbae8b8685c3f9ecb7f5f4d", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b34527202e6eb8cee198efec110996c25c5898f43a4094df157f8d28f27d9efe"},
4
9
"earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"},
5
-
"ex_doc": {:hex, :ex_doc, "0.38.2", "504d25eef296b4dec3b8e33e810bc8b5344d565998cd83914ffe1b8503737c02", [: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", "732f2d972e42c116a70802f9898c51b54916e542cc50968ac6980512ec90f42b"},
6
-
"file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"},
7
-
"finch": {:hex, :finch, "0.19.0", "c644641491ea854fc5c1bbaef36bfc764e3f08e7185e1f084e35e0672241b76d", [: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", "fc5324ce209125d1e2fa0fcd2634601c52a787aff1cd33ee833664a5af4ea2b6"},
10
+
"erlex": {:hex, :erlex, "0.2.8", "cd8116f20f3c0afe376d1e8d1f0ae2452337729f68be016ea544a72f767d9c12", [:mix], [], "hexpm", "9d66ff9fedf69e49dc3fd12831e12a8a37b76f8651dd21cd45fcf5561a8a7590"},
11
+
"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"},
12
+
"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"},
13
+
"file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"},
14
+
"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"},
8
15
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
9
16
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
17
+
"jose": {:hex, :jose, "1.11.12", "06e62b467b61d3726cbc19e9b5489f7549c37993de846dfb3ee8259f9ed208b3", [:mix, :rebar3], [], "hexpm", "31e92b653e9210b696765cdd885437457de1add2a9011d92f8cf63e4641bab7b"},
10
18
"makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"},
11
19
"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"},
12
20
"makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"},
13
21
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
14
22
"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"},
15
23
"multiformats_ex": {:hex, :multiformats_ex, "0.2.0", "5b0a3faa1a770dc671aa8a89b6323cc20b0ecf67dc93dcd21312151fbea6b4ee", [:mix], [{:varint, "~> 1.4", [hex: :varint, repo: "hexpm", optional: false]}], "hexpm", "aa406d9addb06dc197e0e92212992486af6599158d357680f29f2d11e08d0423"},
24
+
"mutex": {:hex, :mutex, "3.0.2", "528877fd0dbc09fc93ad667e10ea0d35a2126fa85205822f9dca85e87d732245", [:mix], [], "hexpm", "0a8f2ed3618160dca6a1e3520b293dc3c2ae53116265e71b4a732d35d29aa3c6"},
16
25
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
17
26
"nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
18
27
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
19
-
"req": {:hex, :req, "0.5.10", "a3a063eab8b7510785a467f03d30a8d95f66f5c3d9495be3474b61459c54376c", [: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", "8a604815743f8a2d3b5de0659fa3137fa4b1cffd636ecb69b30b2b9b2c2559be"},
28
+
"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"},
29
+
"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"},
30
+
"plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
31
+
"recase": {:hex, :recase, "0.9.1", "82d2e2e2d4f9e92da1ce5db338ede2e4f15a50ac1141fc082b80050b9f49d96e", [:mix], [], "hexpm", "19ba03ceb811750e6bec4a015a9f9e45d16a8b9e09187f6d72c3798f454710f3"},
32
+
"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"},
20
33
"telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"},
21
-
"typedstruct": {:hex, :typedstruct, "0.5.3", "d68ae424251a41b81a8d0c485328ab48edbd3858f3565bbdac21b43c056fc9b4", [:make, :mix], [], "hexpm", "b53b8186701417c0b2782bf02a2db5524f879b8488f91d1d83b97d84c2943432"},
34
+
"thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"},
35
+
"typedstruct": {:hex, :typedstruct, "0.5.4", "d1d33d58460a74f413e9c26d55e66fd633abd8ac0fb12639add9a11a60a0462a", [:make, :mix], [], "hexpm", "ffaef36d5dbaebdbf4ed07f7fb2ebd1037b2c1f757db6fb8e7bcbbfabbe608d8"},
22
36
"varint": {:hex, :varint, "1.5.1", "17160c70d0428c3f8a7585e182468cac10bbf165c2360cf2328aaa39d3fb1795", [:mix], [], "hexpm", "24f3deb61e91cb988056de79d06f01161dd01be5e0acae61d8d936a552f1be73"},
37
+
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
23
38
}
+6
priv/templates/lexicon.eex
+6
priv/templates/lexicon.eex
+50
test/atex/oauth/permission_test.exs
+50
test/atex/oauth/permission_test.exs
···
1
+
defmodule Atex.OAuth.PermissionTest do
2
+
use ExUnit.Case, async: true
3
+
alias Atex.OAuth.Permission
4
+
doctest Permission
5
+
6
+
describe "account/1" do
7
+
test "requires `:attr`" do
8
+
assert_raise ArgumentError, ~r/`:attr` must be provided/, fn ->
9
+
Permission.account()
10
+
end
11
+
end
12
+
13
+
test "requires valid `:attr`" do
14
+
assert_raise ArgumentError, ~r/`:attr` must be `:email` or `:repo`/, fn ->
15
+
Permission.account(attr: :foobar)
16
+
end
17
+
18
+
assert Permission.account(attr: :email)
19
+
end
20
+
21
+
test "requires valid `:action`" do
22
+
assert_raise ArgumentError, ~r/`:action` must be `:read`, `:manage`, or `nil`/, fn ->
23
+
Permission.account(attr: :email, action: :foobar)
24
+
end
25
+
26
+
assert Permission.account(attr: :email, action: :manage)
27
+
assert Permission.account(attr: :repo, action: nil)
28
+
end
29
+
end
30
+
31
+
describe "rpc/2" do
32
+
test "requires at least `:aud` or `:inherit_aud`" do
33
+
assert_raise ArgumentError, ~r/must specify either/, fn ->
34
+
Permission.rpc("com.example.getProfile")
35
+
end
36
+
end
37
+
38
+
test "disallows `:aud` and `:inherit_aud` at the same time" do
39
+
assert_raise ArgumentError, ~r/cannot specify both/, fn ->
40
+
Permission.rpc("com.example.getProfile", aud: "example", inherit_aud: true)
41
+
end
42
+
end
43
+
44
+
test "disallows wildcard for `lxm` and `aud` at the same time" do
45
+
assert_raise ArgumentError, ~r/wildcard `lxm` and wildcard `aud`/, fn ->
46
+
Permission.rpc("*", aud: "*")
47
+
end
48
+
end
49
+
end
50
+
end