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

Compare changes

Choose any two refs to compare.

Changed files
+8036 -194
.github
workflows
.vscode
config
examples
lib
atex
atproto
com
atproto
admin
identity
label
lexicon
moderation
repo
server
sync
mix
priv
templates
test
+10
.credo.exs
···
+
%{
+
configs: [
+
%{
+
name: "default",
+
checks: %{
+
disabled: [{Credo.Check.Design.TagTODO, []}]
+
}
+
}
+
]
+
}
+5 -2
.formatter.exs
···
# Used by "mix format"
[
-
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"],
-
import_deps: [:typedstruct]
+
inputs: ["{mix,.formatter,.credo}.exs", "{config,examples,lib,test}/**/*.{ex,exs}"],
+
import_deps: [:typedstruct, :peri, :plug],
+
export: [
+
locals_without_parens: [deflexicon: 1]
+
]
]
+21
.github/workflows/push.yaml
···
+
name: Push
+
on:
+
push:
+
branches:
+
- main
+
+
jobs:
+
docker:
+
name: Lint and test
+
runs-on: ubuntu-latest
+
+
steps:
+
- uses: actions/checkout@v4
+
+
- name: Install Nix
+
uses: nixbuild/nix-quick-install-action@v30
+
+
- run: nix flake check
+
- run: nix develop --command mix deps.get
+
- run: nix develop --command mix credo --mute-exit-status -a
+
- run: nix develop --command mix test
+9 -16
.gitignore
···
-
# The directory Mix will write compiled artifacts to.
/_build/
-
-
# If you run "mix test --cover", coverage assets end up here.
/cover/
-
-
# The directory Mix downloads your dependencies sources to.
/deps/
-
-
# Where third-party dependencies like ExDoc output generated docs.
/doc/
-
-
# If the VM crashes, it generates a dump, let's ignore it too.
erl_crash.dump
-
-
# Also ignore archive artifacts (built via "mix archive.build").
*.ez
-
-
# Ignore package tarball (built via "mix hex.build").
atex-*.tar
-
-
# Temporary files, for example, from tests.
/tmp/
.envrc
-
.direnv
+
.direnv
+
.vscode/
+
.elixir_ls
+
lexicons
+
secrets
+
.DS_Store
+
CLAUDE.md
+
tmp
+
temp
.vscode/settings.json

This is a binary file and will not be displayed.

+89
CHANGELOG.md
···
+
# Changelog
+
+
All notable changes to atex will be documented in this file.
+
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
+
and this project adheres to
+
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
+
<!-- ## [Unreleased] -->
+
+
## [0.6.0] - 2025-11-25
+
+
### Breaking Changes
+
+
- `deflexicon` now converts all def names to be in snake_case instead of the
+
casing as written the lexicon.
+
+
### Added
+
+
- `deflexicon` now emits structs for records, objects, queries, and procedures.
+
- `Atex.XRPC.get/3` and `Atex.XRPC.post/3` now support having a lexicon struct
+
as the second argument instead of the method's name, making it easier to have
+
properly checked XRPC calls.
+
- Add pre-transpiled modules for the core `com.atproto` lexicons.
+
+
## [0.5.0] - 2025-10-11
+
+
### Breaking Changes
+
+
- Remove `Atex.HTTP` and associated modules as the abstraction caused a bit too
+
much complexities for how early atex is. It may come back in the future as
+
something more fleshed out once we're more stable.
+
- Rename `Atex.XRPC.Client` to `Atex.XRPC.LoginClient`
+
+
### Added
+
+
- `Atex.OAuth` module with utilites for handling some OAuth functionality.
+
- `Atex.OAuth.Plug` module (if Plug is loaded) which provides a basic but
+
complete OAuth flow, including storing the tokens in `Plug.Session`.
+
- `Atex.XRPC.Client` behaviour for implementing custom client variants.
+
- `Atex.XRPC` now supports using different client implementations.
+
- `Atex.XRPC.OAuthClient` to make XRPC calls on the behalf of a user who has
+
authenticated with ATProto OAuth.
+
+
## [0.4.0] - 2025-08-27
+
+
### Added
+
+
- `Atex.Lexicon` module that provides the `deflexicon` macro, taking in a JSON
+
Lexicon definition and converts it into a series of schemas for each
+
definition within it.
+
- `mix atex.lexicons` for converting lexicon JSON files into modules using
+
`deflexicon` easily.
+
+
## [0.3.0] - 2025-06-29
+
+
### Changed
+
+
- `Atex.XRPC.Adapter` renamed to `Atex.HTTP.Adapter`.
+
+
### Added
+
+
- `Atex.HTTP` module that delegates to the currently configured adapter.
+
- `Atex.HTTP.Response` struct to be returned by `Atex.HTTP.Adapter`.
+
- `Atex.IdentityResolver` module for resolving and validating an identity,
+
either by DID or a handle.
+
- Also has a pluggable cache (with a default ETS implementation) for keeping
+
some data locally.
+
+
## [0.2.0] - 2025-06-09
+
+
### Added
+
+
- `Atex.TID` module for manipulating ATProto TIDs.
+
- `Atex.Base32Sortable` module for encoding/decoding numbers as
+
`base32-sortable` strings.
+
- Basic XRPC client.
+
+
## [0.1.0] - 2025-06-07
+
+
Initial release.
+
+
[unreleased]: https://github.com/cometsh/atex/compare/v0.6.0...HEAD
+
[0.6.0]: https://github.com/cometsh/atex/releases/tag/v0.6.0
+
[0.5.0]: https://github.com/cometsh/atex/releases/tag/v0.5.0
+
[0.4.0]: https://github.com/cometsh/atex/releases/tag/v0.4.0
+
[0.3.0]: https://github.com/cometsh/atex/releases/tag/v0.3.0
+
[0.2.0]: https://github.com/cometsh/atex/releases/tag/v0.2.0
+
[0.1.0]: https://github.com/cometsh/atex/releases/tag/v0.1.0
+10 -7
README.md
···
## Current Roadmap (in no particular order)
- [x] `at://` parsing and struct
-
- [ ] XRPC client
-
- [ ] CID & TID codecs
-
- [ ] DID & handle resolution service with a cache
-
- [ ] Structs with validation for the common lexicons
-
- [ ] Probably codegen for doing this with other lexicons
-
- [ ] Oauth stuff
+
- [x] TID codecs
+
- [x] XRPC client
+
- [x] DID & handle resolution service with a cache
+
- [x] Macro for converting a Lexicon definition into a runtime-validation schema
+
- [x] Codegen to convert a directory of lexicons
+
- [x] Oauth stuff
+
- [x] Extended XRPC client with support for validated inputs/outputs
+
- [ ] Proper MST & CAR handling things
+
- [ ] Pre-transpiled libraries for popular lexicons
## Installation
···
```elixir
def deps do
[
-
{:atex, "~> 0.1"}
+
{:atex, "~> 0.6"}
]
end
```
+11
bump_builtin_lexicons.sh
···
+
#!/usr/bin/env bash
+
+
set -euo pipefail
+
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+
cd "$SCRIPT_DIR"
+
+
mkdir -p ./tmp
+
git clone --depth 1 --single-branch https://github.com/bluesky-social/atproto.git ./tmp/atproto
+
mix atex.lexicons ./tmp/atproto/lexicons/com/atproto/**/*.json
+
rm -rf ./tmp
+10
config/runtime.exs
···
+
import Config
+
+
config :atex, Atex.OAuth,
+
# base_url: "https://comet.sh/aaaa",
+
base_url: "http://127.0.0.1:4000/oauth",
+
is_localhost: true,
+
scopes: ~w(transition:generic),
+
private_key:
+
"MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgyIpxhuDm0i3mPkrk6UdX4Sd9Jsv6YtAmSTza+A2nArShRANCAAQLF1GLueOBZOVnKWfrcnoDOO9NSRqH2utmfGMz+Rce18MDB7Z6CwFWjEq2UFYNBI4MI5cMI0+m+UYAmj4OZm+m",
+
key_id: "awooga"
+60
examples/oauth.ex
···
+
defmodule ExampleOAuthPlug do
+
use Plug.Router
+
alias Atex.OAuth
+
alias Atex.XRPC
+
+
plug :put_secret_key_base
+
+
plug Plug.Session,
+
store: :cookie,
+
key: "atex-oauth",
+
signing_salt: "signing-salt"
+
+
plug :match
+
plug :dispatch
+
+
forward "/oauth", to: Atex.OAuth.Plug
+
+
get "/whoami" do
+
conn = fetch_session(conn)
+
+
with {:ok, client} <- XRPC.OAuthClient.from_conn(conn),
+
{:ok, response, client} <-
+
XRPC.post(client, %Com.Atproto.Repo.CreateRecord{
+
input: %Com.Atproto.Repo.CreateRecord.Input{
+
repo: client.did,
+
collection: "app.bsky.feed.post",
+
rkey: Atex.TID.now() |> to_string(),
+
record: %App.Bsky.Feed.Post{
+
text: "Hello world from atex!",
+
createdAt: NaiveDateTime.to_iso8601(NaiveDateTime.utc_now())
+
}
+
}
+
}) do
+
IO.inspect(response, label: "output")
+
+
conn
+
|> XRPC.OAuthClient.update_plug(client)
+
|> send_resp(200, response.uri)
+
else
+
:error ->
+
send_resp(conn, 401, "Unauthorized")
+
+
err ->
+
IO.inspect(err, label: "xrpc failed")
+
send_resp(conn, 500, "xrpc failed")
+
end
+
end
+
+
match _ do
+
send_resp(conn, 404, "oops")
+
end
+
+
def put_secret_key_base(conn, _) do
+
put_in(
+
conn.secret_key_base,
+
# Don't use this in production
+
"5ef1078e1617463a3eb3feb9b152e76587a75a6809e0485a125b6bb7ae468f086680771f700d77ff61dfdc8d8ee8a5c7848024a41cf5ad4b6eb3115f74ce6e46"
+
)
+
end
+
end
+3 -3
flake.lock
···
"nodes": {
"nixpkgs": {
"locked": {
-
"lastModified": 1749143949,
-
"narHash": "sha256-QuUtALJpVrPnPeozlUG/y+oIMSLdptHxb3GK6cpSVhA=",
+
"lastModified": 1755615617,
+
"narHash": "sha256-HMwfAJBdrr8wXAkbGhtcby1zGFvs+StOp19xNsbqdOg=",
"owner": "nixos",
"repo": "nixpkgs",
-
"rev": "d3d2d80a2191a73d1e86456a751b83aa13085d7d",
+
"rev": "20075955deac2583bb12f07151c2df830ef346b4",
"type": "github"
},
"original": {
+10
lib/atex/application.ex
···
+
defmodule Atex.Application do
+
@moduledoc false
+
+
use Application
+
+
def start(_type, _args) do
+
children = [Atex.IdentityResolver.Cache]
+
Supervisor.start_link(children, strategy: :one_for_one)
+
end
+
end
+159
lib/atex/aturi.ex
···
+
defmodule Atex.AtURI do
+
@moduledoc """
+
Struct and helper functions for manipulating `at://` URIs, which identify
+
specific records within the AT Protocol.
+
+
ATProto spec: https://atproto.com/specs/at-uri-scheme
+
+
This module only supports the restricted URI syntax used for the Lexicon
+
`at-uri` type, with no support for query strings or fragments. If/when the
+
full syntax gets widespread use, this module will expand to accomodate them.
+
+
Both URIs using DIDs and handles ("example.com") are supported.
+
"""
+
+
use TypedStruct
+
+
@did ~S"did:(?:plc|web):[a-zA-Z0-9._:%-]*[a-zA-Z0-9._-]"
+
@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])?"
+
@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})?)"
+
+
@authority "(?<authority>(?:#{@did})|(?:#{@handle}))"
+
@collection "(?<collection>#{@nsid})"
+
@rkey "(?<rkey>[a-zA-Z0-9.-_:~]{1,512})"
+
+
@re ~r"^at://#{@authority}(?:/#{@collection}(?:/#{@rkey})?)?$"
+
+
typedstruct do
+
field :authority, String.t(), enforce: true
+
field :collection, String.t() | nil
+
field :rkey, String.t() | nil
+
end
+
+
@doc """
+
Create a new AtURI struct from a string by matching it against the regex.
+
+
Returns `{:ok, aturi}` if a valid `at://` URI is given, otherwise it will return `:error`.
+
+
## Examples
+
+
iex> Atex.AtURI.new("at://did:plc:44ybard66vv44zksje25o7dz/app.bsky.feed.post/3jwdwj2ctlk26")
+
{:ok, %Atex.AtURI{
+
rkey: "3jwdwj2ctlk26",
+
collection: "app.bsky.feed.post",
+
authority: "did:plc:44ybard66vv44zksje25o7dz"
+
}}
+
+
iex> Atex.AtURI.new("at:invalid/malformed")
+
:error
+
+
Partial URIs pointing to a collection without a record key, or even just a given authority, are also supported:
+
+
iex> Atex.AtURI.new("at://ovyerus.com/sh.comet.v0.feed.track")
+
{:ok, %Atex.AtURI{
+
rkey: nil,
+
collection: "sh.comet.v0.feed.track",
+
authority: "ovyerus.com"
+
}}
+
+
iex> Atex.AtURI.new("at://did:web:comet.sh")
+
{:ok, %Atex.AtURI{
+
rkey: nil,
+
collection: nil,
+
authority: "did:web:comet.sh"
+
}}
+
"""
+
@spec new(String.t()) :: {:ok, t()} | :error
+
def new(string) when is_binary(string) do
+
# TODO: test different ways to get a good error from regex on which part failed match?
+
case Regex.named_captures(@re, string) do
+
%{} = captures -> {:ok, from_named_captures(captures)}
+
nil -> :error
+
end
+
end
+
+
@doc """
+
The same as `new/1` but raises an `ArgumentError` if an invalid string is given.
+
+
## Examples
+
+
iex> Atex.AtURI.new!("at://did:plc:44ybard66vv44zksje25o7dz/app.bsky.feed.post/3jwdwj2ctlk26")
+
%Atex.AtURI{
+
rkey: "3jwdwj2ctlk26",
+
collection: "app.bsky.feed.post",
+
authority: "did:plc:44ybard66vv44zksje25o7dz"
+
}
+
+
iex> Atex.AtURI.new!("at:invalid/malformed")
+
** (ArgumentError) Malformed at:// URI
+
"""
+
@spec new!(String.t()) :: t()
+
def new!(string) when is_binary(string) do
+
case new(string) do
+
{:ok, uri} -> uri
+
:error -> raise ArgumentError, message: "Malformed at:// URI"
+
end
+
end
+
+
@doc """
+
Check if a string is a valid `at://` URI.
+
+
## Examples
+
+
iex> Atex.AtURI.match?("at://did:plc:44ybard66vv44zksje25o7dz/app.bsky.feed.post/3jwdwj2ctlk26")
+
true
+
+
iex> Atex.AtURI.match?("at://did:web:comet.sh")
+
true
+
+
iex> Atex.AtURI.match?("at://ovyerus.com/sh.comet.v0.feed.track")
+
true
+
+
iex> Atex.AtURI.match?("gobbledy gook")
+
false
+
"""
+
@spec match?(String.t()) :: boolean()
+
def match?(string), do: Regex.match?(@re, string)
+
+
@doc """
+
Format an `Atex.AtURI` to the canonical string representation.
+
+
Also available via the `String.Chars` protocol.
+
+
## Examples
+
+
iex> aturi = %Atex.AtURI{
+
...> rkey: "3jwdwj2ctlk26",
+
...> collection: "app.bsky.feed.post",
+
...> authority: "did:plc:44ybard66vv44zksje25o7dz"
+
...> }
+
iex> Atex.AtURI.to_string(aturi)
+
"at://did:plc:44ybard66vv44zksje25o7dz/app.bsky.feed.post/3jwdwj2ctlk26"
+
+
iex> aturi = %Atex.AtURI{authority: "did:web:comet.sh"}
+
iex> to_string(aturi)
+
"at://did:web:comet.sh"
+
"""
+
@spec to_string(t()) :: String.t()
+
def to_string(%__MODULE__{} = uri) do
+
"at://#{uri.authority}/#{uri.collection}/#{uri.rkey}"
+
|> String.trim_trailing("/")
+
end
+
+
defp from_named_captures(%{"authority" => authority, "collection" => "", "rkey" => ""}),
+
do: %__MODULE__{authority: authority}
+
+
defp from_named_captures(%{"authority" => authority, "collection" => collection, "rkey" => ""}),
+
do: %__MODULE__{authority: authority, collection: collection}
+
+
defp from_named_captures(%{
+
"authority" => authority,
+
"collection" => collection,
+
"rkey" => rkey
+
}),
+
do: %__MODULE__{authority: authority, collection: collection, rkey: rkey}
+
end
+
+
defimpl String.Chars, for: Atex.AtURI do
+
def to_string(uri), do: Atex.AtURI.to_string(uri)
+
end
+39
lib/atex/base32_sortable.ex
···
+
defmodule Atex.Base32Sortable do
+
@moduledoc """
+
Codec for the base32-sortable encoding.
+
"""
+
+
@alphabet ~c(234567abcdefghijklmnopqrstuvwxyz)
+
@alphabet_len length(@alphabet)
+
+
@doc """
+
Encode an integer as a base32-sortable string.
+
"""
+
@spec encode(integer()) :: String.t()
+
def encode(int) when is_integer(int), do: do_encode(int, "")
+
+
@spec do_encode(integer(), String.t()) :: String.t()
+
defp do_encode(0, acc), do: acc
+
+
defp do_encode(int, acc) do
+
char_index = rem(int, @alphabet_len)
+
new_int = div(int, @alphabet_len)
+
+
# Chars are prepended to the accumulator because rem/div is pulling them off the tail of the integer.
+
do_encode(new_int, <<Enum.at(@alphabet, char_index)>> <> acc)
+
end
+
+
@doc """
+
Decode a base32-sortable string to an integer.
+
"""
+
@spec decode(String.t()) :: integer()
+
def decode(str) when is_binary(str), do: do_decode(str, 0)
+
+
@spec do_decode(String.t(), integer()) :: integer()
+
defp do_decode(<<>>, acc), do: acc
+
+
defp do_decode(<<char::utf8, rest::binary>>, acc) do
+
i = Enum.find_index(@alphabet, fn x -> x == char end)
+
do_decode(rest, acc * @alphabet_len + i)
+
end
+
end
+86
lib/atex/config/oauth.ex
···
+
defmodule Atex.Config.OAuth do
+
@moduledoc """
+
Configuration management for `Atex.OAuth`.
+
+
Contains all the logic for fetching configuration needed for the OAuth
+
module, as well as deriving useful values from them.
+
+
## Configuration
+
+
The following structure is expected in your application config:
+
+
config :atex, Atex.OAuth,
+
base_url: "https://example.com/oauth", # Your application's base URL, including the path `Atex.OAuth` is mounted on.
+
private_key: "base64-encoded-private-key", # ES256 private key
+
key_id: "your-key-id", # Key identifier for JWTs
+
scopes: ["transition:generic", "transition:email"], # Optional additional scopes
+
extra_redirect_uris: ["https://alternative.com/callback"], # Optional additional redirect URIs
+
is_localhost: false # Set to true for local development
+
"""
+
+
@doc """
+
Returns the configured public base URL for OAuth routes.
+
"""
+
@spec base_url() :: String.t()
+
def base_url, do: Application.fetch_env!(:atex, Atex.OAuth)[:base_url]
+
+
@doc """
+
Returns the configured private key as a `JOSE.JWK`.
+
"""
+
@spec get_key() :: JOSE.JWK.t()
+
def get_key() do
+
private_key =
+
Application.fetch_env!(:atex, Atex.OAuth)[:private_key]
+
|> Base.decode64!()
+
|> JOSE.JWK.from_der()
+
+
key_id = Application.fetch_env!(:atex, Atex.OAuth)[:key_id]
+
+
%{private_key | fields: %{"kid" => key_id}}
+
end
+
+
@doc """
+
Returns the client ID based on configuration.
+
+
If `is_localhost` is set, it'll be a string handling the "http://localhost"
+
special case, with the redirect URI and scopes configured, otherwise it is a
+
string pointing to the location of the `client-metadata.json` route.
+
"""
+
@spec client_id() :: String.t()
+
def client_id() do
+
is_localhost = Keyword.get(Application.get_env(:atex, Atex.OAuth, []), :is_localhost, false)
+
+
if is_localhost do
+
query =
+
%{redirect_uri: redirect_uri(), scope: scopes()}
+
|> URI.encode_query()
+
+
"http://localhost?#{query}"
+
else
+
"#{base_url()}/client-metadata.json"
+
end
+
end
+
+
@doc """
+
Returns the configured redirect URI.
+
"""
+
@spec redirect_uri() :: String.t()
+
def redirect_uri(), do: "#{base_url()}/callback"
+
+
@doc """
+
Returns the configured scopes joined as a space-separated string.
+
"""
+
@spec scopes() :: String.t()
+
def scopes() do
+
config_scopes = Keyword.get(Application.get_env(:atex, Atex.OAuth, []), :scopes, [])
+
Enum.join(["atproto" | config_scopes], " ")
+
end
+
+
@doc """
+
Returns the configured extra redirect URIs.
+
"""
+
@spec extra_redirect_uris() :: [String.t()]
+
def extra_redirect_uris() do
+
Keyword.get(Application.get_env(:atex, Atex.OAuth, []), :extra_redirect_uris, [])
+
end
+
end
+16
lib/atex/did.ex
···
+
defmodule Atex.DID do
+
@re ~r/^did:[a-z]+:[a-zA-Z0-9._:%-]*[a-zA-Z0-9._-]$/
+
@blessed_re ~r/^did:(?:plc|web):[a-zA-Z0-9._:%-]*[a-zA-Z0-9._-]$/
+
+
@spec re() :: Regex.t()
+
def re, do: @re
+
+
@spec match?(String.t()) :: boolean()
+
def match?(value), do: Regex.match?(@re, value)
+
+
@spec blessed_re() :: Regex.t()
+
def blessed_re, do: @blessed_re
+
+
@spec match_blessed?(String.t()) :: boolean()
+
def match_blessed?(value), do: Regex.match?(@blessed_re, value)
+
end
+9
lib/atex/handle.ex
···
+
defmodule Atex.Handle do
+
@re ~r/^(?:[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])?$/
+
+
@spec re() :: Regex.t()
+
def re, do: @re
+
+
@spec match?(String.t()) :: boolean()
+
def match?(value), do: Regex.match?(@re, value)
+
end
+57
lib/atex/identity_resolver/cache/ets.ex
···
+
defmodule Atex.IdentityResolver.Cache.ETS do
+
alias Atex.IdentityResolver.Identity
+
@behaviour Atex.IdentityResolver.Cache
+
use Supervisor
+
+
@table :atex_identities
+
+
def start_link(opts) do
+
Supervisor.start_link(__MODULE__, opts)
+
end
+
+
@impl Supervisor
+
def init(_opts) do
+
:ets.new(@table, [:set, :public, :named_table])
+
Supervisor.init([], strategy: :one_for_one)
+
end
+
+
@impl Atex.IdentityResolver.Cache
+
@spec insert(Identity.t()) :: Identity.t()
+
def insert(identity) do
+
# TODO: benchmark lookups vs match performance, is it better to use a "composite" key or two inserts?
+
:ets.insert(@table, {{identity.did, identity.handle}, identity})
+
identity
+
end
+
+
@impl Atex.IdentityResolver.Cache
+
@spec get(String.t()) :: {:ok, Identity.t()} | {:error, atom()}
+
def get(identifier) do
+
lookup(identifier)
+
end
+
+
@impl Atex.IdentityResolver.Cache
+
@spec delete(String.t()) :: :noop | Identity.t()
+
def delete(identifier) do
+
case lookup(identifier) do
+
{:ok, identity} ->
+
:ets.delete(@table, {identity.did, identity.handle})
+
identity
+
+
_ ->
+
:noop
+
end
+
end
+
+
defp lookup(identifier) do
+
case :ets.match(@table, {{identifier, :_}, :"$1"}) do
+
[] ->
+
case :ets.match(@table, {{:_, identifier}, :"$1"}) do
+
[] -> {:error, :not_found}
+
[[identity]] -> {:ok, identity}
+
end
+
+
[[identity]] ->
+
{:ok, identity}
+
end
+
end
+
end
+42
lib/atex/identity_resolver/cache.ex
···
+
defmodule Atex.IdentityResolver.Cache do
+
# TODO: need the following:
+
# did -> handle mapping
+
# handle -> did mapping
+
# did -> document mapping?
+
# User should be able to call a single function to fetch all info for either did and handle, including the link between them.
+
# Need some sort of TTL so that we can refresh as necessary
+
alias Atex.IdentityResolver.Identity
+
+
@cache Application.compile_env(:atex, :identity_cache, Atex.IdentityResolver.Cache.ETS)
+
+
@doc """
+
Add a new identity to the cache. Can also be used to update an identity that may already exist.
+
+
Returns the input `t:Atex.IdentityResolver.Identity.t/0`.
+
"""
+
@callback insert(identity :: Identity.t()) :: Identity.t()
+
+
@doc """
+
Retrieve an identity from the cache by DID *or* handle.
+
"""
+
@callback get(String.t()) :: {:ok, Identity.t()} | {:error, atom()}
+
+
@doc """
+
Delete an identity in the cache.
+
"""
+
@callback delete(String.t()) :: :noop | Identity.t()
+
+
@doc """
+
Get the child specification for starting the cache in a supervision tree.
+
"""
+
@callback child_spec(any()) :: Supervisor.child_spec()
+
+
defdelegate get(identifier), to: @cache
+
+
@doc false
+
defdelegate insert(payload), to: @cache
+
@doc false
+
defdelegate delete(snowflake), to: @cache
+
@doc false
+
defdelegate child_spec(opts), to: @cache
+
end
+51
lib/atex/identity_resolver/did.ex
···
+
defmodule Atex.IdentityResolver.DID do
+
alias Atex.IdentityResolver.DIDDocument
+
+
@type resolution_result() ::
+
{:ok, DIDDocument.t()}
+
| {:error, :invalid_did_type | :invalid_did | :not_found | map() | atom() | any()}
+
+
@spec resolve(String.t()) :: resolution_result()
+
def resolve("did:plc:" <> _ = did), do: resolve_plc(did)
+
def resolve("did:web:" <> _ = did), do: resolve_web(did)
+
def resolve("did:" <> _), do: {:error, :invalid_did_type}
+
def resolve(_did), do: {:error, :invalid_did}
+
+
@spec resolve_plc(String.t()) :: resolution_result()
+
defp resolve_plc("did:plc:" <> _id = did) do
+
with {:ok, resp} when resp.status in 200..299 <-
+
Req.get("https://plc.directory/#{did}"),
+
{:ok, body} <- decode_body(resp.body),
+
{:ok, document} <- DIDDocument.from_json(body),
+
:ok <- DIDDocument.validate_for_atproto(document, did) do
+
{:ok, document}
+
else
+
{:ok, %{status: status}} when status in [404, 410] -> {:error, :not_found}
+
{:ok, %{} = resp} -> {:error, resp}
+
e -> e
+
end
+
end
+
+
@spec resolve_web(String.t()) :: resolution_result()
+
defp resolve_web("did:web:" <> domain = did) do
+
with {:ok, resp} when resp.status in 200..299 <-
+
Req.get("https://#{domain}/.well-known/did.json"),
+
{:ok, body} <- decode_body(resp.body),
+
{:ok, document} <- DIDDocument.from_json(body),
+
:ok <- DIDDocument.validate_for_atproto(document, did) do
+
{:ok, document}
+
else
+
{:ok, %{status: 404}} -> {:error, :not_found}
+
{:ok, %{} = resp} -> {:error, resp}
+
e -> e
+
end
+
end
+
+
@spec decode_body(any()) ::
+
{:ok, any()}
+
| {:error, :invalid_body | JSON.decode_error_reason()}
+
+
defp decode_body(body) when is_binary(body), do: JSON.decode(body)
+
defp decode_body(body) when is_map(body), do: {:ok, body}
+
defp decode_body(_body), do: {:error, :invalid_body}
+
end
+155
lib/atex/identity_resolver/did_document.ex
···
+
defmodule Atex.IdentityResolver.DIDDocument do
+
@moduledoc """
+
Struct and schema for describing and validating a [DID document](https://github.com/w3c/did-wg/blob/main/did-explainer.md#did-documents).
+
"""
+
import Peri
+
use TypedStruct
+
+
defschema :schema, %{
+
"@context": {:required, {:list, Atex.Peri.uri()}},
+
id: {:required, :string},
+
controller: {:either, {Atex.Peri.did(), {:list, Atex.Peri.did()}}},
+
also_known_as: {:list, Atex.Peri.uri()},
+
verification_method: {:list, get_schema(:verification_method)},
+
authentication: {:list, {:either, {Atex.Peri.uri(), get_schema(:verification_method)}}},
+
service: {:list, get_schema(:service)}
+
}
+
+
defschema :verification_method, %{
+
id: {:required, Atex.Peri.uri()},
+
type: {:required, :string},
+
controller: {:required, Atex.Peri.did()},
+
public_key_multibase: :string,
+
public_key_jwk: :map
+
}
+
+
defschema :service, %{
+
id: {:required, Atex.Peri.uri()},
+
type: {:required, {:either, {:string, {:list, :string}}}},
+
service_endpoint:
+
{:required,
+
{:oneof,
+
[
+
Atex.Peri.uri(),
+
{:map, Atex.Peri.uri()},
+
{:list, {:either, {Atex.Peri.uri(), {:map, Atex.Peri.uri()}}}}
+
]}}
+
}
+
+
@type verification_method() :: %{
+
required(:id) => String.t(),
+
required(:type) => String.t(),
+
required(:controller) => String.t(),
+
optional(:public_key_multibase) => String.t(),
+
optional(:public_key_jwk) => map()
+
}
+
+
@type service() :: %{
+
required(:id) => String.t(),
+
required(:type) => String.t() | list(String.t()),
+
required(:service_endpoint) =>
+
String.t()
+
| %{String.t() => String.t()}
+
| list(String.t() | %{String.t() => String.t()})
+
}
+
+
typedstruct do
+
field :"@context", list(String.t()), enforce: true
+
field :id, String.t(), enforce: true
+
field :controller, String.t() | list(String.t())
+
field :also_known_as, list(String.t())
+
field :verification_method, list(verification_method())
+
field :authentication, list(String.t() | verification_method())
+
field :service, list(service())
+
end
+
+
def new(params), do: struct(__MODULE__, params)
+
+
@spec from_json(map()) :: {:ok, t()} | {:error, Peri.Error.t()}
+
def from_json(%{} = map) do
+
map
+
|> Recase.Enumerable.convert_keys(&Recase.to_snake/1)
+
|> schema()
+
|> case do
+
# {:ok, params} -> {:ok, struct(__MODULE__, params)}
+
{:ok, params} -> {:ok, new(params)}
+
e -> e
+
end
+
end
+
+
@spec validate_for_atproto(t(), String.t()) :: any()
+
def validate_for_atproto(%__MODULE__{} = doc, did) do
+
# TODO: make sure this is ok
+
id_matches = doc.id == did
+
+
valid_signing_key =
+
Enum.any?(doc.verification_method, fn method ->
+
String.ends_with?(method.id, "#atproto") and method.controller == did
+
end)
+
+
valid_pds_service =
+
Enum.any?(doc.service, fn service ->
+
String.ends_with?(service.id, "#atproto_pds") and
+
service.type == "AtprotoPersonalDataServer" and
+
valid_pds_endpoint?(service.service_endpoint)
+
end)
+
+
case {id_matches, valid_signing_key, valid_pds_service} do
+
{true, true, true} -> :ok
+
{false, _, _} -> {:error, :id_mismatch}
+
{_, false, _} -> {:error, :no_signing_key}
+
{_, _, false} -> {:error, :invalid_pds}
+
end
+
end
+
+
@doc """
+
Get the associated ATProto handle in the DID document.
+
+
ATProto dictates that only the first valid handle is to be used, so this
+
follows that rule.
+
+
> #### Note {: .info}
+
>
+
> While DID documents are fairly authoritative, you need to make sure to
+
> validate the handle bidirectionally. See
+
> `Atex.IdentityResolver.Handle.resolve/2`.
+
"""
+
@spec get_atproto_handle(t()) :: String.t() | nil
+
def get_atproto_handle(%__MODULE__{also_known_as: nil}), do: nil
+
+
def get_atproto_handle(%__MODULE__{} = doc) do
+
Enum.find_value(doc.also_known_as, fn
+
# TODO: make sure no path or other URI parts
+
"at://" <> handle -> handle
+
_ -> nil
+
end)
+
end
+
+
@spec get_pds_endpoint(t()) :: String.t() | nil
+
def get_pds_endpoint(%__MODULE__{} = doc) do
+
doc.service
+
|> Enum.find(fn
+
%{id: "#atproto_pds", type: "AtprotoPersonalDataServer"} -> true
+
_ -> false
+
end)
+
|> case do
+
nil -> nil
+
pds -> pds.service_endpoint
+
end
+
end
+
+
defp valid_pds_endpoint?(endpoint) do
+
case URI.new(endpoint) do
+
{:ok, uri} ->
+
is_plain_uri =
+
uri
+
|> Map.from_struct()
+
|> Enum.all?(fn
+
{key, value} when key in [:userinfo, :path, :query, :fragment] -> is_nil(value)
+
_ -> true
+
end)
+
+
uri.scheme in ["https", "http"] and is_plain_uri
+
end
+
end
+
end
+74
lib/atex/identity_resolver/handle.ex
···
+
defmodule Atex.IdentityResolver.Handle do
+
@type strategy() :: :dns_first | :http_first | :race | :both
+
+
@spec resolve(String.t(), strategy()) ::
+
{:ok, String.t()} | :error | {:error, :ambiguous_handle}
+
def resolve(handle, strategy)
+
+
def resolve(handle, :dns_first) do
+
case resolve_via_dns(handle) do
+
:error -> resolve_via_http(handle)
+
ok -> ok
+
end
+
end
+
+
def resolve(handle, :http_first) do
+
case resolve_via_http(handle) do
+
:error -> resolve_via_dns(handle)
+
ok -> ok
+
end
+
end
+
+
def resolve(handle, :race) do
+
[&resolve_via_dns/1, &resolve_via_http/1]
+
|> Task.async_stream(& &1.(handle), max_concurrency: 2, ordered: false)
+
|> Stream.filter(&match?({:ok, {:ok, _}}, &1))
+
|> Enum.at(0)
+
end
+
+
def resolve(handle, :both) do
+
case Task.await_many([
+
Task.async(fn -> resolve_via_dns(handle) end),
+
Task.async(fn -> resolve_via_http(handle) end)
+
]) do
+
[{:ok, dns_did}, {:ok, http_did}] ->
+
if dns_did && http_did && dns_did != http_did do
+
{:error, :ambiguous_handle}
+
else
+
{:ok, dns_did}
+
end
+
+
_ ->
+
:error
+
end
+
end
+
+
@spec resolve_via_dns(String.t()) :: {:ok, String.t()} | :error
+
defp resolve_via_dns(handle) do
+
with ["did=" <> did] <- query_dns("_atproto.#{handle}", :txt),
+
"did:" <> _ <- did do
+
{:ok, did}
+
else
+
_ -> :error
+
end
+
end
+
+
@spec resolve_via_http(String.t()) :: {:ok, String.t()} | :error
+
defp resolve_via_http(handle) do
+
case Req.get("https://#{handle}/.well-known/atproto-did") do
+
{:ok, %{body: "did:" <> _ = did}} -> {:ok, did}
+
_ -> :error
+
end
+
end
+
+
@spec query_dns(String.t(), :inet_res.dns_rr_type()) :: list(String.t() | list(String.t()))
+
defp query_dns(domain, type) do
+
domain
+
|> String.to_charlist()
+
|> :inet_res.lookup(:in, type)
+
|> Enum.map(fn
+
[result] -> to_string(result)
+
result -> result
+
end)
+
end
+
end
+25
lib/atex/identity_resolver/identity.ex
···
+
defmodule Atex.IdentityResolver.Identity do
+
use TypedStruct
+
+
@typedoc """
+
The controlling DID for an identity.
+
"""
+
@type did() :: String.t()
+
@typedoc """
+
The human-readable handle for an identity. Can be missing.
+
"""
+
@type handle() :: String.t() | nil
+
@typedoc """
+
The resolved DID document for an identity.
+
"""
+
@type document() :: Atex.IdentityResolver.DIDDocument.t()
+
+
typedstruct do
+
field :did, did(), enforce: true
+
field :handle, handle()
+
field :document, document(), enforce: true
+
end
+
+
@spec new(did(), handle(), document()) :: t()
+
def new(did, handle, document), do: %__MODULE__{did: did, handle: handle, document: document}
+
end
+56
lib/atex/identity_resolver.ex
···
+
defmodule Atex.IdentityResolver do
+
alias Atex.IdentityResolver.{Cache, DID, DIDDocument, Handle, Identity}
+
+
@handle_strategy Application.compile_env(:atex, :handle_resolver_strategy, :dns_first)
+
@type options() :: {:skip_cache, boolean()}
+
+
# TODO: simplify errors
+
+
@spec resolve(String.t(), list(options())) :: {:ok, Identity.t()} | {:error, any()}
+
def resolve(identifier, opts \\ []) do
+
opts = Keyword.validate!(opts, skip_cache: false)
+
skip_cache = Keyword.get(opts, :skip_cache)
+
+
cache_result = if skip_cache, do: {:error, :not_found}, else: Cache.get(identifier)
+
+
# If cache fetch succeeds, then the ok tuple will be retuned by the default `with` behaviour
+
with {:error, :not_found} <- cache_result,
+
{:ok, identity} <- do_resolve(identifier),
+
identity <- Cache.insert(identity) do
+
{:ok, identity}
+
end
+
end
+
+
@spec do_resolve(identity :: String.t()) ::
+
{:ok, Identity.t()}
+
| {:error, :handle_mismatch}
+
| {:error, any()}
+
defp do_resolve("did:" <> _ = did) do
+
with {:ok, document} <- DID.resolve(did),
+
:ok <- DIDDocument.validate_for_atproto(document, did) do
+
with handle when not is_nil(handle) <- DIDDocument.get_atproto_handle(document),
+
{:ok, handle_did} <- Handle.resolve(handle, @handle_strategy),
+
true <- handle_did == did do
+
{:ok, Identity.new(did, handle, document)}
+
else
+
# Not having a handle, while a little un-ergonomic, is totally valid.
+
nil -> {:ok, Identity.new(did, nil, document)}
+
false -> {:error, :handle_mismatch}
+
e -> e
+
end
+
end
+
end
+
+
defp do_resolve(handle) do
+
with {:ok, did} <- Handle.resolve(handle, @handle_strategy),
+
{:ok, document} <- DID.resolve(did),
+
did_handle when not is_nil(handle) <- DIDDocument.get_atproto_handle(document),
+
true <- did_handle == handle do
+
{:ok, Identity.new(did, handle, document)}
+
else
+
nil -> {:error, :handle_mismatch}
+
false -> {:error, :handle_mismatch}
+
e -> e
+
end
+
end
+
end
+264
lib/atex/lexicon/schema.ex
···
+
defmodule Atex.Lexicon.Schema do
+
import Peri
+
+
defschema :lexicon, %{
+
lexicon: {:required, {:literal, 1}},
+
id: {:required, {:string, {:regex, Atex.NSID.re()}}},
+
revision: {:integer, {:gte, 0}},
+
description: :string,
+
defs: {
+
:required,
+
{:schema,
+
%{
+
main:
+
{:oneof,
+
[
+
get_schema(:record),
+
get_schema(:query),
+
get_schema(:procedure),
+
get_schema(:subscription),
+
get_schema(:user_types)
+
]}
+
}, {:additional_keys, get_schema(:user_types)}}
+
}
+
}
+
+
defschema :record, %{
+
type: {:required, {:literal, "record"}},
+
description: :string,
+
# TODO: constraint
+
key: {:required, :string},
+
record: {:required, get_schema(:object)}
+
}
+
+
defschema :query, %{
+
type: {:required, {:literal, "query"}},
+
description: :string,
+
parameters: get_schema(:parameters),
+
output: get_schema(:body),
+
errors: {:list, get_schema(:error)}
+
}
+
+
defschema :procedure, %{
+
type: {:required, {:literal, "procedure"}},
+
description: :string,
+
parameters: get_schema(:parameters),
+
input: get_schema(:body),
+
output: get_schema(:body),
+
errors: {:list, get_schema(:error)}
+
}
+
+
defschema :subscription, %{
+
type: {:required, {:literal, "subscription"}},
+
description: :string,
+
parameters: get_schema(:parameters),
+
message: %{
+
description: :string,
+
schema: {:oneof, [get_schema(:object), get_schema(:ref_variant)]}
+
},
+
errors: {:list, get_schema(:error)}
+
}
+
+
defschema :parameters, %{
+
type: {:required, {:literal, "params"}},
+
description: :string,
+
# required: {{:list, :string}, {:default, []}},
+
required: {:list, :string},
+
properties:
+
{:required, {:map, {:either, {get_schema(:primitive), get_schema(:primitive_array)}}}}
+
}
+
+
defschema :body, %{
+
description: :string,
+
encoding: {:required, :string},
+
schema: {:oneof, [get_schema(:object), get_schema(:ref_variant)]}
+
}
+
+
defschema :error, %{
+
name: {:required, :string},
+
description: :string
+
}
+
+
defschema :user_types,
+
{:oneof,
+
[
+
get_schema(:blob),
+
get_schema(:array),
+
get_schema(:token),
+
get_schema(:object),
+
get_schema(:boolean),
+
get_schema(:integer),
+
get_schema(:string),
+
get_schema(:bytes),
+
get_schema(:cid_link),
+
get_schema(:unknown)
+
]}
+
+
# General types
+
+
@ref_value {:string,
+
{
+
:regex,
+
# TODO: minlength 1
+
~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])?)?$/
+
}}
+
+
@positive_int {:integer, {:gte, 0}}
+
@nonzero_positive_int {:integer, {:gt, 0}}
+
+
defschema :ref_variant, {:oneof, [get_schema(:ref), get_schema(:ref_union)]}
+
+
defschema :ref, %{
+
type: {:required, {:literal, "ref"}},
+
description: :string,
+
ref: {:required, @ref_value}
+
}
+
+
defschema :ref_union, %{
+
type: {:required, {:literal, "union"}},
+
description: :string,
+
refs: {:required, {:list, @ref_value}}
+
}
+
+
defschema :array, %{
+
type: {:required, {:literal, "array"}},
+
description: :string,
+
items:
+
{:required,
+
{:oneof,
+
[get_schema(:primitive), get_schema(:ipld), get_schema(:blob), get_schema(:ref_variant)]}},
+
maxLength: @positive_int,
+
minLength: @positive_int
+
}
+
+
defschema :primitive_array, %{
+
type: {:required, {:literal, "array"}},
+
description: :string,
+
items: {:required, get_schema(:primitive)},
+
maxLength: @positive_int,
+
minLength: @positive_int
+
}
+
+
defschema :object, %{
+
type: {:required, {:literal, "object"}},
+
description: :string,
+
# required: {{:list, :string}, {:default, []}},
+
# nullable: {{:list, :string}, {:default, []}},
+
required: {:list, :string},
+
nullable: {:list, :string},
+
properties:
+
{:required,
+
{:map,
+
{:oneof,
+
[
+
get_schema(:ref_variant),
+
get_schema(:ipld),
+
get_schema(:array),
+
get_schema(:blob),
+
get_schema(:primitive)
+
]}}}
+
}
+
+
defschema :primitive,
+
{:oneof,
+
[
+
get_schema(:boolean),
+
get_schema(:integer),
+
get_schema(:string),
+
get_schema(:unknown)
+
]}
+
+
defschema :ipld, {:oneof, [get_schema(:bytes), get_schema(:cid_link)]}
+
+
defschema :blob, %{
+
type: {:required, {:literal, "blob"}},
+
description: :string,
+
accept: {:list, :string},
+
maxSize: @positive_int
+
}
+
+
defschema :boolean, %{
+
type: {:required, {:literal, "boolean"}},
+
description: :string,
+
default: :boolean,
+
const: :boolean
+
}
+
+
defschema :bytes, %{
+
type: {:required, {:literal, "bytes"}},
+
description: :string,
+
maxLength: @positive_int,
+
minLength: @positive_int
+
}
+
+
defschema :cid_link, %{
+
type: {:required, {:literal, "cid-link"}},
+
description: :string
+
}
+
+
@string_type {:required, {:literal, "string"}}
+
+
defschema :string,
+
{:either,
+
{
+
# Formatted
+
%{
+
type: @string_type,
+
format:
+
{:required,
+
{:enum,
+
[
+
"at-identifier",
+
"at-uri",
+
"cid",
+
"datetime",
+
"did",
+
"handle",
+
"language",
+
"nsid",
+
"record-key",
+
"tid",
+
"uri"
+
]}},
+
description: :string,
+
default: :string,
+
const: :string,
+
enum: {:list, :string},
+
knownValues: {:list, :string}
+
},
+
# Unformatted
+
%{
+
type: @string_type,
+
description: :string,
+
default: :string,
+
const: :string,
+
enum: {:list, :string},
+
knownValues: {:list, :string},
+
format: {:literal, nil},
+
maxLength: @nonzero_positive_int,
+
minLength: @nonzero_positive_int,
+
maxGraphemes: @nonzero_positive_int,
+
minGraphemes: @nonzero_positive_int
+
}
+
}}
+
+
defschema :integer, %{
+
type: {:required, {:literal, "integer"}},
+
description: :string,
+
default: @positive_int,
+
const: @positive_int,
+
enum: {:list, @positive_int},
+
maximum: @positive_int,
+
minimum: @positive_int
+
}
+
+
defschema :token, %{
+
type: {:required, {:literal, "token"}},
+
description: :string
+
}
+
+
defschema :unknown, %{
+
type: {:required, {:literal, "unknown"}},
+
description: :string
+
}
+
end
+52
lib/atex/lexicon/validators/array.ex
···
+
defmodule Atex.Lexicon.Validators.Array do
+
@type option() :: {:min_length, non_neg_integer()} | {:max_length, non_neg_integer()}
+
+
@option_keys [:min_length, :max_length]
+
+
# Needs type input
+
@spec validate(term(), Peri.schema_def(), list(option())) :: Peri.validation_result()
+
def validate(value, inner_type, options) when is_list(value) do
+
# TODO: validate inner_type with Peri to make sure it's correct?
+
+
options
+
|> Keyword.validate!(min_length: nil, max_length: nil)
+
|> Stream.map(&validate_option(value, &1))
+
|> Enum.find(:ok, fn x -> x != :ok end)
+
|> case do
+
:ok ->
+
value
+
|> Stream.map(&Peri.validate(inner_type, &1))
+
|> Enum.find({:ok, nil}, fn
+
{:ok, _} -> false
+
{:error, _} -> true
+
end)
+
|> case do
+
{:ok, _} -> :ok
+
e -> e
+
end
+
+
e ->
+
e
+
end
+
end
+
+
def validate(_inner_type, value, _options),
+
do: {:error, "expected type of `array`, received #{value}", [expected: :array, actual: value]}
+
+
@spec validate_option(list(), option()) :: Peri.validation_result()
+
defp validate_option(value, option)
+
+
defp validate_option(_value, {option, nil}) when option in @option_keys, do: :ok
+
+
defp validate_option(value, {:min_length, expected}) when length(value) >= expected,
+
do: :ok
+
+
defp validate_option(value, {:min_length, expected}) when length(value) < expected,
+
do: {:error, "should have a minimum length of #{expected}", [length: expected]}
+
+
defp validate_option(value, {:max_length, expected}) when length(value) <= expected,
+
do: :ok
+
+
defp validate_option(value, {:max_length, expected}) when length(value) > expected,
+
do: {:error, "should have a maximum length of #{expected}", [length: expected]}
+
end
+32
lib/atex/lexicon/validators/bytes.ex
···
+
defmodule Atex.Lexicon.Validators.Bytes do
+
@type option() :: {:min_length, pos_integer()} | {:max_length, pos_integer()}
+
+
@option_keys [:min_length, :max_length]
+
+
@spec validate(term(), list(option())) :: Peri.validation_result()
+
def validate(value, options) when is_binary(value) do
+
case Base.decode64(value, padding: false) do
+
{:ok, bytes} ->
+
options
+
|> Keyword.validate!(min_length: nil, max_length: nil)
+
|> Stream.map(&validate_option(bytes, &1))
+
|> Enum.find(:ok, fn x -> x !== :ok end)
+
+
:error ->
+
{:error, "expected valid base64 encoded bytes", []}
+
end
+
end
+
+
def validate(value, _options),
+
do:
+
{:error, "expected valid base64 encoded bytes, received #{value}",
+
[expected: :bytes, actual: value]}
+
+
defp validate_option(_value, {option, nil}) when option in @option_keys, do: :ok
+
+
defp validate_option(value, {:min_length, expected}) when byte_size(value) < expected,
+
do: {:error, "should have a minimum byte length of #{expected}", [length: expected]}
+
+
defp validate_option(value, {:max_length, expected}) when byte_size(value) <= expected,
+
do: :ok
+
end
+38
lib/atex/lexicon/validators/integer.ex
···
+
defmodule Atex.Lexicon.Validators.Integer do
+
@type option() ::
+
{:minimum, integer()}
+
| {:maximum, integer()}
+
+
@option_keys [:minimum, :maximum]
+
+
@spec validate(term(), list(option())) :: Peri.validation_result()
+
def validate(value, options) when is_integer(value) do
+
options
+
|> Keyword.validate!(
+
minimum: nil,
+
maximum: nil
+
)
+
|> Stream.map(&validate_option(value, &1))
+
|> Enum.find(:ok, fn x -> x != :ok end)
+
end
+
+
def validate(value, _options),
+
do:
+
{:error, "expected type of `integer`, received #{value}",
+
[expected: :integer, actual: value]}
+
+
@spec validate_option(integer(), option()) :: Peri.validation_result()
+
defp validate_option(value, option)
+
+
defp validate_option(_value, {option, nil}) when option in @option_keys, do: :ok
+
+
defp validate_option(value, {:minimum, expected}) when value >= expected, do: :ok
+
+
defp validate_option(value, {:minimum, expected}) when value < expected,
+
do: {:error, "", [value: expected]}
+
+
defp validate_option(value, {:maximum, expected}) when value <= expected, do: :ok
+
+
defp validate_option(value, {:maximum, expected}) when value > expected,
+
do: {:error, "", [value: expected]}
+
end
+134
lib/atex/lexicon/validators/string.ex
···
+
defmodule Atex.Lexicon.Validators.String do
+
alias Atex.Lexicon.Validators
+
+
@type option() ::
+
{:format, String.t()}
+
| {:min_length, non_neg_integer()}
+
| {:max_length, non_neg_integer()}
+
| {:min_graphemes, non_neg_integer()}
+
| {:max_graphemes, non_neg_integer()}
+
+
@option_keys [
+
:format,
+
:min_length,
+
:max_length,
+
:min_graphemes,
+
:max_graphemes
+
]
+
+
@record_key_re ~r"^[a-zA-Z0-9.-_:~]$"
+
+
@spec validate(term(), list(option())) :: Peri.validation_result()
+
def validate(value, options) when is_binary(value) do
+
options
+
|> Keyword.validate!(
+
format: nil,
+
min_length: nil,
+
max_length: nil,
+
min_graphemes: nil,
+
max_graphemes: nil
+
)
+
# Stream so we early exit at the first error.
+
|> Stream.map(&validate_option(value, &1))
+
|> Enum.find(:ok, fn x -> x != :ok end)
+
end
+
+
def validate(value, _options),
+
do:
+
{:error, "expected type of `string`, received #{value}", [expected: :string, actual: value]}
+
+
@spec validate_option(String.t(), option()) :: Peri.validation_result()
+
defp validate_option(value, option)
+
+
defp validate_option(_value, {option, nil}) when option in @option_keys, do: :ok
+
+
defp validate_option(value, {:format, "at-identifier"}),
+
do:
+
Validators.boolean_validate(
+
Atex.DID.match?(value) or Atex.Handle.match?(value),
+
"should be a valid DID or handle"
+
)
+
+
defp validate_option(value, {:format, "at-uri"}),
+
do: Validators.boolean_validate(Atex.AtURI.match?(value), "should be a valid at:// URI")
+
+
defp validate_option(value, {:format, "cid"}) do
+
# TODO: is there a regex provided by the lexicon docs/somewhere?
+
try do
+
Multiformats.CID.decode(value)
+
:ok
+
rescue
+
_ -> {:error, "should be a valid CID", []}
+
end
+
end
+
+
defp validate_option(value, {:format, "datetime"}) do
+
# NaiveDateTime is used over DateTime because the result isn't actually
+
# being used, so we don't need to include a calendar library just for this.
+
case NaiveDateTime.from_iso8601(value) do
+
{:ok, _} -> :ok
+
{:error, _} -> {:error, "should be a valid datetime", []}
+
end
+
end
+
+
defp validate_option(value, {:format, "did"}),
+
do: Validators.boolean_validate(Atex.DID.match?(value), "should be a valid DID")
+
+
defp validate_option(value, {:format, "handle"}),
+
do: Validators.boolean_validate(Atex.Handle.match?(value), "should be a valid handle")
+
+
defp validate_option(value, {:format, "nsid"}),
+
do: Validators.boolean_validate(Atex.NSID.match?(value), "should be a valid NSID")
+
+
defp validate_option(value, {:format, "tid"}),
+
do: Validators.boolean_validate(Atex.TID.match?(value), "should be a valid TID")
+
+
defp validate_option(value, {:format, "record-key"}),
+
do:
+
Validators.boolean_validate(
+
Regex.match?(@record_key_re, value),
+
"should be a valid record key"
+
)
+
+
defp validate_option(value, {:format, "uri"}) do
+
case URI.new(value) do
+
{:ok, _} -> :ok
+
{:error, _} -> {:error, "should be a valid URI", []}
+
end
+
end
+
+
defp validate_option(value, {:format, "language"}) do
+
case Cldr.LanguageTag.parse(value) do
+
{:ok, _} -> :ok
+
{:error, _} -> {:error, "should be a valid BCP 47 language tag", []}
+
end
+
end
+
+
defp validate_option(value, {:min_length, expected}) when byte_size(value) >= expected,
+
do: :ok
+
+
defp validate_option(value, {:min_length, expected}) when byte_size(value) < expected,
+
do: {:error, "should have a minimum byte length of #{expected}", [length: expected]}
+
+
defp validate_option(value, {:max_length, expected}) when byte_size(value) <= expected,
+
do: :ok
+
+
defp validate_option(value, {:max_length, expected}) when byte_size(value) > expected,
+
do: {:error, "should have a maximum byte length of #{expected}", [length: expected]}
+
+
defp validate_option(value, {:min_graphemes, expected}),
+
do:
+
Validators.boolean_validate(
+
String.length(value) >= expected,
+
"should have a minimum length of #{expected}",
+
length: expected
+
)
+
+
defp validate_option(value, {:max_graphemes, expected}),
+
do:
+
Validators.boolean_validate(
+
String.length(value) <= expected,
+
"should have a maximum length of #{expected}",
+
length: expected
+
)
+
end
+106
lib/atex/lexicon/validators.ex
···
+
defmodule Atex.Lexicon.Validators do
+
alias Atex.Lexicon.Validators
+
+
@type blob_option() :: {:accept, list(String.t())} | {:max_size, pos_integer()}
+
+
@type blob() ::
+
%{
+
"$type": String.t(),
+
ref: %{"$link": String.t()},
+
mimeType: String.t(),
+
size: integer()
+
}
+
| %{
+
cid: String.t(),
+
mimeType: String.t()
+
}
+
+
@type cid_link() :: %{"$link": String.t()}
+
+
@type bytes() :: %{"$bytes": binary()}
+
+
@spec string(list(Validators.String.option())) :: Peri.custom_def()
+
def string(options \\ []), do: {:custom, {Validators.String, :validate, [options]}}
+
+
@spec integer(list(Validators.Integer.option())) :: Peri.custom_def()
+
def integer(options \\ []), do: {:custom, {Validators.Integer, :validate, [options]}}
+
+
@spec array(Peri.schema_def(), list(Validators.Array.option())) :: Peri.custom_def()
+
def array(inner_type, options \\ []) do
+
{:custom, {Validators.Array, :validate, [inner_type, options]}}
+
end
+
+
@spec blob(list(blob_option())) :: Peri.schema_def()
+
def blob(options \\ []) do
+
options = Keyword.validate!(options, accept: nil, max_size: nil)
+
accept = Keyword.get(options, :accept)
+
max_size = Keyword.get(options, :max_size)
+
+
mime_type =
+
{:required,
+
if(accept,
+
do: {:string, {:regex, strings_to_re(accept)}},
+
else: {:string, {:regex, ~r"^.+/.+$"}}
+
)}
+
+
{
+
:either,
+
{
+
# Newer blobs
+
%{
+
"$type": {:required, {:literal, "blob"}},
+
ref: {:required, %{"$link": {:required, :string}}},
+
mimeType: mime_type,
+
size: {:required, if(max_size != nil, do: {:integer, {:lte, max_size}}, else: :integer)}
+
},
+
# Old deprecated blobs
+
%{
+
cid: {:required, :string},
+
mimeType: mime_type
+
}
+
}
+
}
+
end
+
+
@spec bytes(list(Validators.Bytes.option())) :: Peri.schema()
+
def bytes(options \\ []) do
+
options = Keyword.validate!(options, min_length: nil, max_length: nil)
+
+
%{
+
"$bytes":
+
{:required,
+
{{:custom, {Validators.Bytes, :validate, [options]}}, {:transform, &Base.decode64!/1}}}
+
}
+
end
+
+
# TODO: see what atcute validators expect
+
# TODO: cid validation?
+
def cid_link() do
+
%{
+
"$link": {:required, :string}
+
}
+
end
+
+
@spec lazy_ref(module(), atom()) :: Peri.schema()
+
def lazy_ref(module, schema_name) do
+
{:custom, {module, schema_name, []}}
+
end
+
+
@spec boolean_validate(boolean(), String.t(), keyword() | map()) ::
+
Peri.validation_result()
+
def boolean_validate(success?, error_message, context \\ []) do
+
if success? do
+
:ok
+
else
+
{:error, error_message, context}
+
end
+
end
+
+
@spec strings_to_re(list(String.t())) :: Regex.t()
+
defp strings_to_re(strings) do
+
strings
+
|> Enum.map(&String.replace(&1, "*", ".+"))
+
|> Enum.join("|")
+
|> then(&~r/^(#{&1})$/)
+
end
+
end
+669
lib/atex/lexicon.ex
···
+
defmodule Atex.Lexicon do
+
alias Atex.Lexicon.Validators
+
+
defmacro __using__(_opts) do
+
quote do
+
import Atex.Lexicon
+
import Atex.Lexicon.Validators
+
import Peri
+
end
+
end
+
+
@doc """
+
Defines a lexicon module from a JSON lexicon definition.
+
+
The `deflexicon` macro processes the provided lexicon map (typically loaded
+
from a JSON file) and generates:
+
+
- **Typespecs** for each definition, exposing a `t/0` type for the main
+
definition and named types for any additional definitions.
+
- **`Peri` schemas** via `defschema/2` for runtime validation of data.
+
- **Structs** for object and record definitions, with `@enforce_keys` ensuring
+
required fields are present.
+
- For **queries** and **procedures**, it creates structs for `params`,
+
`input`, and `output` when those sections exist in the lexicon. It also
+
generates a topโ€‘level struct that aggregates `params` and `input` (when
+
applicable); this struct is used by the XRPC client to locate the
+
appropriate output struct.
+
+
If a procedure doesn't have a schema for a JSON body specified as it's input,
+
the top-level struct will instead have a `raw_input` field, allowing for
+
miscellaneous bodies such as a binary blob.
+
+
The generated structs also implement the `JSON.Encoder` and `Jason.Encoder`
+
protocols (the latter currently present for compatibility), as well as a
+
`from_json` function which is used to validate an input map - e.g. from a JSON
+
HTTP response - and turn it into a struct.
+
+
## Example
+
+
deflexicon(%{
+
"lexicon" => 1,
+
"id" => "com.ovyerus.testing",
+
"defs" => %{
+
"main" => %{
+
"type" => "record",
+
"key" => "tid",
+
"record" => %{
+
"type" => "object",
+
"required" => ["foobar"],
+
"properties" => %{ "foobar" => %{ "type" => "string" } }
+
}
+
}
+
}
+
})
+
+
The macro expands to following code (truncated for brevity):
+
+
@type main() :: %{required(:foobar) => String.t(), optional(:"$type") => String.t()}
+
@type t() :: %{required(:foobar) => String.t(), optional(:"$type") => String.t()}
+
+
defschema(:main, %{
+
foobar: {:required, {:custom, {Atex.Lexicon.Validators.String, :validate, [[]]}}},
+
"$type": {{:literal, "com.ovyerus.testing"}, {:default, "com.ovyerus.testing"}}
+
})
+
+
@enforce_keys [:foobar]
+
defstruct foobar: nil, "$type": "com.ovyerus.testing"
+
+
def from_json(json) do
+
case apply(Com.Ovyerus.Testing, :main, [json]) do
+
{:ok, map} -> {:ok, struct(__MODULE__, map)}
+
err -> err
+
end
+
end
+
+
The generated module can be used directly with `Atex.XRPC` functions, allowing
+
typeโ€‘safe construction of requests and automatic decoding of responses.
+
"""
+
defmacro deflexicon(lexicon) do
+
# Better way to get the real map, without having to eval? (custom function to compose one from quoted?)
+
lexicon =
+
lexicon
+
|> Code.eval_quoted()
+
|> elem(0)
+
|> then(&Recase.Enumerable.atomize_keys/1)
+
|> then(&Atex.Lexicon.Schema.lexicon!/1)
+
+
defs =
+
lexicon.defs
+
|> Enum.flat_map(fn {def_name, def} -> def_to_schema(lexicon.id, def_name, def) end)
+
|> Enum.map(fn
+
{schema_key, quoted_schema, quoted_type} -> {schema_key, quoted_schema, quoted_type, nil}
+
x -> x
+
end)
+
|> Enum.map(fn {schema_key, quoted_schema, quoted_type, quoted_struct} ->
+
identity_type =
+
if schema_key == :main do
+
quote do
+
@type t() :: unquote(quoted_type)
+
end
+
end
+
+
struct_def =
+
if schema_key == :main do
+
quoted_struct
+
else
+
nested_module_name =
+
schema_key
+
|> Recase.to_pascal()
+
|> atomise()
+
+
quote do
+
defmodule unquote({:__aliases__, [alias: false], [nested_module_name]}) do
+
unquote(quoted_struct)
+
end
+
end
+
end
+
+
quote do
+
@type unquote(Recase.to_snake(schema_key))() :: unquote(quoted_type)
+
unquote(identity_type)
+
+
defschema unquote(Recase.to_snake(schema_key)), unquote(quoted_schema)
+
+
unquote(struct_def)
+
end
+
end)
+
+
quote do
+
def id, do: unquote(lexicon.id)
+
+
unquote_splicing(defs)
+
end
+
end
+
+
# - [ ] `t()` type should be the struct in it. (add to non-main structs too?)
+
+
@spec def_to_schema(nsid :: String.t(), def_name :: String.t(), lexicon_def :: map()) ::
+
list(
+
{
+
key :: atom(),
+
quoted_schema :: term(),
+
quoted_type :: term()
+
}
+
| {
+
key :: atom(),
+
quoted_schema :: term(),
+
quoted_type :: term(),
+
quoted_struct :: term()
+
}
+
)
+
+
defp def_to_schema(nsid, def_name, %{type: "record", record: record}) do
+
# TODO: record rkey format validator
+
type_name = Atex.NSID.canonical_name(nsid, to_string(def_name))
+
+
record =
+
put_in(record, [:properties, :"$type"], %{
+
type: "string",
+
const: type_name,
+
default: type_name
+
})
+
+
def_to_schema(nsid, def_name, record)
+
end
+
+
# TODO: add struct to types
+
defp def_to_schema(
+
nsid,
+
def_name,
+
%{
+
type: "object",
+
properties: properties
+
} = def
+
) do
+
required = Map.get(def, :required, [])
+
nullable = Map.get(def, :nullable, [])
+
+
{quoted_schemas, quoted_types} =
+
properties
+
|> Enum.map(fn {key, field} ->
+
{quoted_schema, quoted_type} = field_to_schema(field, nsid)
+
string_key = to_string(key)
+
is_nullable = string_key in nullable
+
is_required = string_key in required
+
+
quoted_schema =
+
quoted_schema
+
|> then(
+
&if is_nullable, do: quote(do: {:either, {{:literal, nil}, unquote(&1)}}), else: &1
+
)
+
|> then(&if is_required, do: quote(do: {:required, unquote(&1)}), else: &1)
+
|> then(&{key, &1})
+
+
key_type = if is_required, do: :required, else: :optional
+
+
quoted_type =
+
quoted_type
+
|> then(
+
&if is_nullable do
+
{:|, [], [&1, nil]}
+
else
+
&1
+
end
+
)
+
|> then(&{{key_type, [], [key]}, &1})
+
+
{quoted_schema, quoted_type}
+
end)
+
|> Enum.reduce({[], []}, fn {quoted_schema, quoted_type}, {schemas, types} ->
+
{[quoted_schema | schemas], [quoted_type | types]}
+
end)
+
+
struct_keys =
+
properties
+
|> Enum.filter(fn {key, _} -> key !== :"$type" end)
+
|> Enum.map(fn
+
{key, %{default: default}} -> {key, default}
+
{key, _field} -> {key, nil}
+
end)
+
|> then(&(&1 ++ [{:"$type", if(def_name == :main, do: nsid, else: "#{nsid}##{def_name}")}]))
+
+
enforced_keys =
+
properties |> Map.keys() |> Enum.filter(&(to_string(&1) in required && &1 != :"$type"))
+
+
optional_if_nil_keys =
+
properties
+
|> Map.keys()
+
|> Enum.filter(fn key ->
+
key = to_string(key)
+
# TODO: what if it is nullable but not required?
+
key not in required && key not in nullable && key != "$type"
+
end)
+
+
schema_module = Atex.NSID.to_atom(nsid)
+
+
quoted_struct =
+
quote do
+
@enforce_keys unquote(enforced_keys)
+
defstruct unquote(struct_keys)
+
+
def from_json(json) do
+
case apply(unquote(schema_module), unquote(atomise(def_name)), [json]) do
+
{:ok, map} -> {:ok, struct(__MODULE__, map)}
+
err -> err
+
end
+
end
+
+
defimpl JSON.Encoder do
+
@optional_if_nil_keys unquote(optional_if_nil_keys)
+
+
def encode(value, encoder) do
+
value
+
|> Map.from_struct()
+
|> Enum.reject(fn {k, v} -> k in @optional_if_nil_keys && v == nil end)
+
|> Enum.into(%{})
+
|> Jason.Encoder.encode(encoder)
+
end
+
end
+
+
defimpl Jason.Encoder do
+
@optional_if_nil_keys unquote(optional_if_nil_keys)
+
+
def encode(value, options) do
+
value
+
|> Map.from_struct()
+
|> Enum.reject(fn {k, v} -> k in @optional_if_nil_keys && v == nil end)
+
|> Enum.into(%{})
+
|> Jason.Encode.map(options)
+
end
+
end
+
end
+
+
[{atomise(def_name), {:%{}, [], quoted_schemas}, {:%{}, [], quoted_types}, quoted_struct}]
+
end
+
+
# TODO: validating errors?
+
defp def_to_schema(nsid, _def_name, %{type: "query"} = def) do
+
params =
+
if def[:parameters] do
+
[schema] =
+
def_to_schema(nsid, "params", %{
+
type: "object",
+
required: Map.get(def.parameters, :required, []),
+
properties: def.parameters.properties
+
})
+
+
schema
+
end
+
+
output =
+
if def[:output] && def.output[:schema] do
+
[schema] = def_to_schema(nsid, "output", def.output.schema)
+
schema
+
end
+
+
# Root struct containing `params`
+
main =
+
if params do
+
{
+
:main,
+
nil,
+
quote do
+
%__MODULE__{params: params()}
+
end,
+
quote do
+
@enforce_keys [:params]
+
defstruct params: nil
+
end
+
}
+
else
+
{
+
:main,
+
nil,
+
quote do
+
%__MODULE__{}
+
end,
+
quote do
+
defstruct []
+
end
+
}
+
end
+
+
[main, params, output]
+
|> Enum.reject(&is_nil/1)
+
end
+
+
defp def_to_schema(nsid, _def_name, %{type: "procedure"} = def) do
+
# TODO: better keys for these
+
params =
+
if def[:parameters] do
+
[schema] =
+
def_to_schema(nsid, "params", %{
+
type: "object",
+
required: Map.get(def.parameters, :required, []),
+
properties: def.parameters.properties
+
})
+
+
schema
+
end
+
+
output =
+
if def[:output] && def.output[:schema] do
+
[schema] = def_to_schema(nsid, "output", def.output.schema)
+
schema
+
end
+
+
input =
+
if def[:input] && def.input[:schema] do
+
[schema] = def_to_schema(nsid, "input", def.input.schema)
+
schema
+
end
+
+
# Root struct containing `input`, `raw_input`, and `params`
+
main =
+
{
+
:main,
+
nil,
+
cond do
+
params && input ->
+
quote do
+
%__MODULE__{input: input(), params: params()}
+
end
+
+
input ->
+
quote do
+
%__MODULE__{input: input()}
+
end
+
+
params ->
+
quote do
+
%__MODULE__{raw_input: any(), params: params()}
+
end
+
+
true ->
+
quote do
+
%__MODULE__{raw_input: any()}
+
end
+
end,
+
cond do
+
params && input ->
+
quote do
+
defstruct input: nil, params: nil
+
end
+
+
input ->
+
quote do
+
defstruct input: nil
+
end
+
+
params ->
+
quote do
+
defstruct raw_input: nil, params: nil
+
end
+
+
true ->
+
quote do
+
defstruct raw_input: nil
+
end
+
end
+
}
+
+
[main, params, output, input]
+
|> Enum.reject(&is_nil/1)
+
end
+
+
defp def_to_schema(nsid, _def_name, %{type: "subscription"} = def) do
+
params =
+
if def[:parameters] do
+
[schema] =
+
def_to_schema(nsid, "params", %{
+
type: "object",
+
required: Map.get(def.parameters, :required, []),
+
properties: def.parameters.properties
+
})
+
+
schema
+
end
+
+
message =
+
if def[:message] do
+
[schema] = def_to_schema(nsid, "message", def.message.schema)
+
schema
+
end
+
+
[params, message]
+
|> Enum.reject(&is_nil/1)
+
end
+
+
defp def_to_schema(_nsid, def_name, %{type: "token"}) do
+
# TODO: make it a validator that expects the nsid + key.
+
[
+
{
+
atomise(def_name),
+
:string,
+
quote do
+
String.t()
+
end
+
}
+
]
+
end
+
+
defp def_to_schema(nsid, def_name, %{type: type} = def)
+
when type in [
+
"blob",
+
"array",
+
"boolean",
+
"integer",
+
"string",
+
"bytes",
+
"cid-link",
+
"unknown",
+
"ref",
+
"union"
+
] do
+
{quoted_schema, quoted_type} = field_to_schema(def, nsid)
+
[{atomise(def_name), quoted_schema, quoted_type}]
+
end
+
+
@spec field_to_schema(field_def :: %{type: String.t()}, nsid :: String.t()) ::
+
{quoted_schema :: term(), quoted_typespec :: term()}
+
defp field_to_schema(%{type: "string"} = field, _nsid) do
+
fixed_schema = const_or_enum(field)
+
+
if fixed_schema do
+
maybe_default(fixed_schema, field)
+
else
+
field
+
|> Map.take([
+
:format,
+
:maxLength,
+
:minLength,
+
:maxGraphemes,
+
:minGraphemes
+
])
+
|> Enum.map(fn {k, v} -> {Recase.to_snake(k), v} end)
+
|> Validators.string()
+
|> maybe_default(field)
+
end
+
|> then(
+
&{Macro.escape(&1),
+
quote do
+
String.t()
+
end}
+
)
+
end
+
+
defp field_to_schema(%{type: "boolean"} = field, _nsid) do
+
(const(field) || :boolean)
+
|> maybe_default(field)
+
|> then(
+
&{Macro.escape(&1),
+
quote do
+
boolean()
+
end}
+
)
+
end
+
+
defp field_to_schema(%{type: "integer"} = field, _nsid) do
+
fixed_schema = const_or_enum(field)
+
+
if fixed_schema do
+
maybe_default(fixed_schema, field)
+
else
+
field
+
|> Map.take([:maximum, :minimum])
+
|> Keyword.new()
+
|> Validators.integer()
+
|> maybe_default(field)
+
end
+
|> then(
+
&{
+
Macro.escape(&1),
+
# TODO: turn into range definition based on maximum/minimum
+
quote do
+
integer()
+
end
+
}
+
)
+
end
+
+
defp field_to_schema(%{type: "array", items: items} = field, nsid) do
+
{inner_schema, inner_type} = field_to_schema(items, nsid)
+
+
field
+
|> Map.take([:maxLength, :minLength])
+
|> Enum.map(fn {k, v} -> {Recase.to_snake(k), v} end)
+
|> then(&Validators.array(inner_schema, &1))
+
|> then(&Macro.escape/1)
+
# TODO: we should be able to unquote this now...
+
# Can't unquote the inner_schema beforehand as that would risk evaluating `get_schema`s which don't exist yet.
+
# There's probably a better way to do this lol.
+
|> then(fn {:custom, {:{}, c, [Validators.Array, :validate, [quoted_inner_schema | args]]}} ->
+
{inner_schema, _} = Code.eval_quoted(quoted_inner_schema)
+
{:custom, {:{}, c, [Validators.Array, :validate, [inner_schema | args]]}}
+
end)
+
|> then(
+
&{&1,
+
quote do
+
list(unquote(inner_type))
+
end}
+
)
+
end
+
+
defp field_to_schema(%{type: "blob"} = field, _nsid) do
+
field
+
|> Map.take([:accept, :maxSize])
+
|> Enum.map(fn {k, v} -> {Recase.to_snake(k), v} end)
+
|> Validators.blob()
+
|> then(
+
&{Macro.escape(&1),
+
quote do
+
Validators.blob()
+
end}
+
)
+
end
+
+
defp field_to_schema(%{type: "bytes"} = field, _nsid) do
+
field
+
|> Map.take([:maxLength, :minLength])
+
|> Enum.map(fn {k, v} -> {Recase.to_snake(k), v} end)
+
|> Validators.bytes()
+
|> then(
+
&{Macro.escape(&1),
+
quote do
+
Validators.bytes()
+
end}
+
)
+
end
+
+
defp field_to_schema(%{type: "cid-link"}, _nsid) do
+
Validators.cid_link()
+
|> then(
+
&{Macro.escape(&1),
+
quote do
+
Validators.cid_link()
+
end}
+
)
+
end
+
+
# TODO: do i need to make sure these two deal with brands? Check objects in atp.tools
+
defp field_to_schema(%{type: "ref", ref: ref}, nsid) do
+
{nsid, fragment} =
+
nsid
+
|> Atex.NSID.expand_possible_fragment_shorthand(ref)
+
|> Atex.NSID.to_atom_with_fragment()
+
+
fragment = Recase.to_snake(fragment)
+
+
{
+
Macro.escape(Validators.lazy_ref(nsid, fragment)),
+
quote do
+
unquote(nsid).unquote(fragment)()
+
end
+
}
+
end
+
+
defp field_to_schema(%{type: "union", refs: refs}, nsid) do
+
if refs == [] do
+
{quote do
+
{:oneof, []}
+
end, nil}
+
else
+
refs
+
|> Enum.map(fn ref ->
+
{nsid, fragment} =
+
nsid
+
|> Atex.NSID.expand_possible_fragment_shorthand(ref)
+
|> Atex.NSID.to_atom_with_fragment()
+
+
fragment = Recase.to_snake(fragment)
+
+
{
+
Macro.escape(Validators.lazy_ref(nsid, fragment)),
+
quote do
+
unquote(nsid).unquote(fragment)()
+
end
+
}
+
end)
+
|> Enum.reduce({[], []}, fn {quoted_schema, quoted_type}, {schemas, types} ->
+
{[quoted_schema | schemas], [quoted_type | types]}
+
end)
+
|> then(fn {schemas, types} ->
+
{quote do
+
{:oneof, unquote(schemas)}
+
end,
+
quote do
+
unquote(join_with_pipe(types))
+
end}
+
end)
+
end
+
end
+
+
# TODO: apparently should be a data object, not a primitive?
+
defp field_to_schema(%{type: "unknown"}, _nsid) do
+
{:any,
+
quote do
+
term()
+
end}
+
end
+
+
defp field_to_schema(_field_def, _nsid), do: {nil, nil}
+
+
defp maybe_default(schema, field) do
+
if field[:default] != nil,
+
do: {schema, {:default, field.default}},
+
else: schema
+
end
+
+
defp const_or_enum(field), do: const(field) || enum(field)
+
+
defp const(%{const: value}), do: {:literal, value}
+
defp const(_), do: nil
+
+
defp enum(%{enum: values}), do: {:enum, values}
+
defp enum(_), do: nil
+
+
defp atomise(x) when is_atom(x), do: x
+
defp atomise(x) when is_binary(x), do: String.to_atom(x)
+
+
defp join_with_pipe(list) when is_list(list) do
+
[piped] = do_join_with_pipe(list)
+
piped
+
end
+
+
defp do_join_with_pipe([head]), do: [head]
+
defp do_join_with_pipe([head | tail]), do: [{:|, [], [head | do_join_with_pipe(tail)]}]
+
defp do_join_with_pipe([]), do: []
+
end
+57
lib/atex/nsid.ex
···
+
defmodule Atex.NSID do
+
@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})?)$/
+
# TODO: regex with support for fragment
+
+
@spec re() :: Regex.t()
+
def re, do: @re
+
+
@spec match?(String.t()) :: boolean()
+
def match?(value), do: Regex.match?(@re, value)
+
+
# TODO: methods for fetching the authority and name from a nsid.
+
# maybe stuff for fetching the repo that belongs to an authority
+
+
@spec to_atom(String.t()) :: atom()
+
def to_atom(nsid, fully_qualify \\ true) do
+
nsid
+
|> String.split(".")
+
|> Enum.map(&Recase.to_pascal/1)
+
|> then(fn parts ->
+
if fully_qualify do
+
["Elixir" | parts]
+
else
+
parts
+
end
+
end)
+
|> Enum.join(".")
+
|> String.to_atom()
+
end
+
+
@spec to_atom_with_fragment(String.t()) :: {atom(), atom()}
+
def to_atom_with_fragment(nsid) do
+
if !String.contains?(nsid, "#") do
+
{to_atom(nsid), :main}
+
else
+
[nsid, fragment] = String.split(nsid, "#")
+
{to_atom(nsid), String.to_atom(fragment)}
+
end
+
end
+
+
@spec expand_possible_fragment_shorthand(String.t(), String.t()) :: String.t()
+
def expand_possible_fragment_shorthand(main_nsid, possible_fragment) do
+
if String.starts_with?(possible_fragment, "#") do
+
main_nsid <> possible_fragment
+
else
+
possible_fragment
+
end
+
end
+
+
@spec canonical_name(String.t(), String.t()) :: String.t()
+
def canonical_name(nsid, fragment) do
+
if fragment == "main" do
+
nsid
+
else
+
"#{nsid}##{fragment}"
+
end
+
end
+
end
+165
lib/atex/oauth/plug.ex
···
+
defmodule Atex.OAuth.Plug do
+
@moduledoc """
+
Plug router for handling AT Protocol's OAuth flow.
+
+
This module provides three endpoints:
+
+
- `GET /login?handle=<handle>` - Initiates the OAuth authorization flow for
+
a given handle
+
- `GET /callback` - Handles the OAuth callback after user authorization
+
- `GET /client-metadata.json` - Serves the OAuth client metadata
+
+
## Usage
+
+
This module requires `Plug.Session` to be in your pipeline, as well as
+
`secret_key_base` to have been set on your connections. Ideally it should be
+
routed to via `Plug.Router.forward/2`, under a route like "/oauth".
+
+
## Example
+
+
Example implementation showing how to set up the OAuth plug with proper
+
session handling:
+
+
defmodule ExampleOAuthPlug do
+
use Plug.Router
+
+
plug :put_secret_key_base
+
+
plug Plug.Session,
+
store: :cookie,
+
key: "atex-oauth",
+
signing_salt: "signing-salt"
+
+
plug :match
+
plug :dispatch
+
+
forward "/oauth", to: Atex.OAuth.Plug
+
+
def put_secret_key_base(conn, _) do
+
put_in(
+
conn.secret_key_base,
+
"very long key base with at least 64 bytes"
+
)
+
end
+
end
+
+
## Session Storage
+
+
After successful authentication, the plug stores these in the session:
+
+
- `:tokens` - The access token response containing access_token,
+
refresh_token, did, and expires_at
+
- `:dpop_nonce` -
+
- `:dpop_key` - The DPoP JWK for generating DPoP proofs
+
"""
+
require Logger
+
use Plug.Router
+
require Plug.Router
+
alias Atex.OAuth
+
alias Atex.{IdentityResolver, IdentityResolver.DIDDocument}
+
+
@oauth_cookie_opts [path: "/", http_only: true, secure: true, same_site: "lax", max_age: 600]
+
+
plug :match
+
plug :dispatch
+
+
get "/login" do
+
conn = fetch_query_params(conn)
+
handle = conn.query_params["handle"]
+
+
if !handle do
+
send_resp(conn, 400, "Need `handle` query parameter")
+
else
+
case IdentityResolver.resolve(handle) do
+
{:ok, identity} ->
+
pds = DIDDocument.get_pds_endpoint(identity.document)
+
{:ok, authz_server} = OAuth.get_authorization_server(pds)
+
{:ok, authz_metadata} = OAuth.get_authorization_server_metadata(authz_server)
+
state = OAuth.create_nonce()
+
code_verifier = OAuth.create_nonce()
+
+
case OAuth.create_authorization_url(
+
authz_metadata,
+
state,
+
code_verifier,
+
handle
+
) do
+
{:ok, authz_url} ->
+
conn
+
|> put_resp_cookie("state", state, @oauth_cookie_opts)
+
|> put_resp_cookie("code_verifier", code_verifier, @oauth_cookie_opts)
+
|> put_resp_cookie("issuer", authz_metadata.issuer, @oauth_cookie_opts)
+
|> put_resp_header("location", authz_url)
+
|> send_resp(307, "")
+
+
err ->
+
Logger.error("failed to reate authorization url, #{inspect(err)}")
+
send_resp(conn, 500, "Internal server error")
+
end
+
+
{:error, err} ->
+
Logger.error("Failed to resolve handle, #{inspect(err)}")
+
send_resp(conn, 400, "Invalid handle")
+
end
+
end
+
end
+
+
get "/client-metadata.json" do
+
conn
+
|> put_resp_content_type("application/json")
+
|> send_resp(200, JSON.encode_to_iodata!(OAuth.create_client_metadata()))
+
end
+
+
get "/callback" do
+
conn = conn |> fetch_query_params() |> fetch_session()
+
cookies = get_cookies(conn)
+
stored_state = cookies["state"]
+
stored_code_verifier = cookies["code_verifier"]
+
stored_issuer = cookies["issuer"]
+
+
code = conn.query_params["code"]
+
state = conn.query_params["state"]
+
+
if !stored_state || !stored_code_verifier || !stored_issuer || (!code || !state) ||
+
stored_state != state do
+
send_resp(conn, 400, "Invalid request")
+
else
+
with {:ok, authz_metadata} <- OAuth.get_authorization_server_metadata(stored_issuer),
+
dpop_key <- JOSE.JWK.generate_key({:ec, "P-256"}),
+
{:ok, tokens, nonce} <-
+
OAuth.validate_authorization_code(
+
authz_metadata,
+
dpop_key,
+
code,
+
stored_code_verifier
+
),
+
{:ok, identity} <- IdentityResolver.resolve(tokens.did),
+
# Make sure pds' issuer matches the stored one (just in case)
+
pds <- DIDDocument.get_pds_endpoint(identity.document),
+
{:ok, authz_server} <- OAuth.get_authorization_server(pds),
+
true <- authz_server == stored_issuer do
+
conn
+
|> delete_resp_cookie("state", @oauth_cookie_opts)
+
|> delete_resp_cookie("code_verifier", @oauth_cookie_opts)
+
|> delete_resp_cookie("issuer", @oauth_cookie_opts)
+
|> put_session(:atex_oauth, %{
+
access_token: tokens.access_token,
+
refresh_token: tokens.refresh_token,
+
did: tokens.did,
+
pds: pds,
+
expires_at: tokens.expires_at,
+
dpop_nonce: nonce,
+
dpop_key: dpop_key
+
})
+
|> send_resp(200, "success!! hello #{tokens.did}")
+
else
+
false ->
+
send_resp(conn, 400, "OAuth issuer does not match your PDS' authorization server")
+
+
err ->
+
Logger.error("failed to validate oauth callback: #{inspect(err)}")
+
send_resp(conn, 500, "Internal server error")
+
end
+
end
+
end
+
end
+531
lib/atex/oauth.ex
···
+
defmodule Atex.OAuth do
+
@moduledoc """
+
OAuth 2.0 implementation for AT Protocol authentication.
+
+
This module provides utilities for implementing OAuth flows compliant with the
+
AT Protocol specification. It includes support for:
+
+
- Pushed Authorization Requests (PAR)
+
- DPoP (Demonstration of Proof of Possession) tokens
+
- JWT client assertions
+
- PKCE (Proof Key for Code Exchange)
+
- Token refresh
+
- Handle to PDS resolution
+
+
## Configuration
+
+
See `Atex.Config.OAuth` module for configuration documentation.
+
+
## Usage Example
+
+
iex> pds = "https://bsky.social"
+
iex> login_hint = "example.com"
+
iex> {:ok, authz_server} = Atex.OAuth.get_authorization_server(pds)
+
iex> {:ok, authz_metadata} = Atex.OAuth.get_authorization_server_metadata(authz_server)
+
iex> state = Atex.OAuth.create_nonce()
+
iex> code_verifier = Atex.OAuth.create_nonce()
+
iex> {:ok, auth_url} = Atex.OAuth.create_authorization_url(
+
authz_metadata,
+
state,
+
code_verifier,
+
login_hint
+
)
+
"""
+
+
@type authorization_metadata() :: %{
+
issuer: String.t(),
+
par_endpoint: String.t(),
+
token_endpoint: String.t(),
+
authorization_endpoint: String.t()
+
}
+
+
@type tokens() :: %{
+
access_token: String.t(),
+
refresh_token: String.t(),
+
did: String.t(),
+
expires_at: NaiveDateTime.t()
+
}
+
+
alias Atex.Config.OAuth, as: Config
+
+
@doc """
+
Get a map cnotaining the client metadata information needed for an
+
authorization server to validate this client.
+
"""
+
@spec create_client_metadata() :: map()
+
def create_client_metadata() do
+
key = Config.get_key()
+
{_, jwk} = key |> JOSE.JWK.to_public_map()
+
jwk = Map.merge(jwk, %{use: "sig", kid: key.fields["kid"]})
+
+
%{
+
client_id: Config.client_id(),
+
redirect_uris: [Config.redirect_uri() | Config.extra_redirect_uris()],
+
application_type: "web",
+
grant_types: ["authorization_code", "refresh_token"],
+
scope: Config.scopes(),
+
response_type: ["code"],
+
token_endpoint_auth_method: "private_key_jwt",
+
token_endpoint_auth_signing_alg: "ES256",
+
dpop_bound_access_tokens: true,
+
jwks: %{keys: [jwk]}
+
}
+
end
+
+
@doc """
+
Retrieves the configured JWT private key for signing client assertions.
+
+
Loads the private key from configuration, decodes the base64-encoded DER data,
+
and creates a JOSE JWK structure with the key ID field set.
+
+
## Returns
+
+
A `JOSE.JWK` struct containing the private key and key identifier.
+
+
## Raises
+
+
* `Application.Env.Error` if the private_key or key_id configuration is missing
+
+
## Examples
+
+
key = OAuth.get_key()
+
key = OAuth.get_key()
+
"""
+
@spec get_key() :: JOSE.JWK.t()
+
def get_key(), do: Config.get_key()
+
+
@doc false
+
@spec random_b64(integer()) :: String.t()
+
def random_b64(length) do
+
:crypto.strong_rand_bytes(length)
+
|> Base.url_encode64(padding: false)
+
end
+
+
@doc false
+
@spec create_nonce() :: String.t()
+
def create_nonce(), do: random_b64(32)
+
+
@doc """
+
Create an OAuth authorization URL for a PDS.
+
+
Submits a PAR request to the authorization server and constructs the
+
authorization URL with the returned request URI. Supports PKCE, DPoP, and
+
client assertions as required by the AT Protocol.
+
+
## Parameters
+
+
- `authz_metadata` - Authorization server metadata containing endpoints, fetched from `get_authorization_server_metadata/1`
+
- `state` - Random token for session validation
+
- `code_verifier` - PKCE code verifier
+
- `login_hint` - User identifier (handle or DID) for pre-filled login
+
+
## Returns
+
+
- `{:ok, authorization_url}` - Successfully created authorization URL
+
- `{:ok, :invalid_par_response}` - Server respondend incorrectly to the request
+
- `{:error, reason}` - Error creating authorization URL
+
"""
+
@spec create_authorization_url(
+
authorization_metadata(),
+
String.t(),
+
String.t(),
+
String.t()
+
) :: {:ok, String.t()} | {:error, any()}
+
def create_authorization_url(
+
authz_metadata,
+
state,
+
code_verifier,
+
login_hint
+
) do
+
code_challenge = :crypto.hash(:sha256, code_verifier) |> Base.url_encode64(padding: false)
+
key = get_key()
+
+
client_assertion =
+
create_client_assertion(key, Config.client_id(), authz_metadata.issuer)
+
+
body =
+
%{
+
response_type: "code",
+
client_id: Config.client_id(),
+
redirect_uri: Config.redirect_uri(),
+
state: state,
+
code_challenge_method: "S256",
+
code_challenge: code_challenge,
+
scope: Config.scopes(),
+
client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
+
client_assertion: client_assertion,
+
login_hint: login_hint
+
}
+
+
case Req.post(authz_metadata.par_endpoint, form: body) do
+
{:ok, %{body: %{"request_uri" => request_uri}}} ->
+
query =
+
%{client_id: Config.client_id(), request_uri: request_uri}
+
|> URI.encode_query()
+
+
{:ok, "#{authz_metadata.authorization_endpoint}?#{query}"}
+
+
{:ok, _} ->
+
{:error, :invalid_par_response}
+
+
err ->
+
err
+
end
+
end
+
+
@doc """
+
Exchange an OAuth authorization code for a set of access and refresh tokens.
+
+
Validates the authorization code by submitting it to the token endpoint along with
+
the PKCE code verifier and client assertion. Returns access tokens for making authenticated
+
requests to the relevant user's PDS.
+
+
## Parameters
+
+
- `authz_metadata` - Authorization server metadata containing token endpoint
+
- `dpop_key` - JWK for DPoP token generation
+
- `code` - Authorization code from OAuth callback
+
- `code_verifier` - PKCE code verifier from authorization flow
+
+
## Returns
+
+
- `{:ok, tokens, nonce}` - Successfully obtained tokens with returned DPoP nonce
+
- `{:error, reason}` - Error exchanging code for tokens
+
"""
+
@spec validate_authorization_code(
+
authorization_metadata(),
+
JOSE.JWK.t(),
+
String.t(),
+
String.t()
+
) :: {:ok, tokens(), String.t()} | {:error, any()}
+
def validate_authorization_code(
+
authz_metadata,
+
dpop_key,
+
code,
+
code_verifier
+
) do
+
key = get_key()
+
+
client_assertion =
+
create_client_assertion(key, Config.client_id(), authz_metadata.issuer)
+
+
body =
+
%{
+
grant_type: "authorization_code",
+
client_id: Config.client_id(),
+
redirect_uri: Config.redirect_uri(),
+
code: code,
+
code_verifier: code_verifier,
+
client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
+
client_assertion: client_assertion
+
}
+
+
Req.new(method: :post, url: authz_metadata.token_endpoint, form: body)
+
|> send_oauth_dpop_request(dpop_key)
+
|> case do
+
{:ok,
+
%{
+
"access_token" => access_token,
+
"refresh_token" => refresh_token,
+
"expires_in" => expires_in,
+
"sub" => did
+
}, nonce} ->
+
expires_at = NaiveDateTime.utc_now() |> NaiveDateTime.add(expires_in, :second)
+
+
{:ok,
+
%{
+
access_token: access_token,
+
refresh_token: refresh_token,
+
did: did,
+
expires_at: expires_at
+
}, nonce}
+
+
err ->
+
err
+
end
+
end
+
+
def refresh_token(refresh_token, dpop_key, issuer, token_endpoint) do
+
key = get_key()
+
+
client_assertion =
+
create_client_assertion(key, Config.client_id(), issuer)
+
+
body = %{
+
grant_type: "refresh_token",
+
refresh_token: refresh_token,
+
client_id: Config.client_id(),
+
client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
+
client_assertion: client_assertion
+
}
+
+
Req.new(method: :post, url: token_endpoint, form: body)
+
|> send_oauth_dpop_request(dpop_key)
+
|> case do
+
{:ok,
+
%{
+
"access_token" => access_token,
+
"refresh_token" => refresh_token,
+
"expires_in" => expires_in,
+
"sub" => did
+
}, nonce} ->
+
expires_at = NaiveDateTime.utc_now() |> NaiveDateTime.add(expires_in, :second)
+
+
{:ok,
+
%{
+
access_token: access_token,
+
refresh_token: refresh_token,
+
did: did,
+
expires_at: expires_at
+
}, nonce}
+
+
err ->
+
err
+
end
+
end
+
+
@doc """
+
Fetch the authorization server for a given Personal Data Server (PDS).
+
+
Makes a request to the PDS's `.well-known/oauth-protected-resource` endpoint
+
to discover the associated authorization server that should be used for the
+
OAuth flow.
+
+
## Parameters
+
+
- `pds_host` - Base URL of the PDS (e.g., "https://bsky.social")
+
+
## Returns
+
+
- `{:ok, authorization_server}` - Successfully discovered authorization
+
server URL
+
- `{:error, :invalid_metadata}` - Server returned invalid metadata
+
- `{:error, reason}` - Error discovering authorization server
+
"""
+
@spec get_authorization_server(String.t()) :: {:ok, String.t()} | {:error, any()}
+
def get_authorization_server(pds_host) do
+
"#{pds_host}/.well-known/oauth-protected-resource"
+
|> Req.get()
+
|> case do
+
# TODO: what to do when multiple authorization servers?
+
{:ok, %{body: %{"authorization_servers" => [authz_server | _]}}} -> {:ok, authz_server}
+
{:ok, _} -> {:error, :invalid_metadata}
+
err -> err
+
end
+
end
+
+
@doc """
+
Fetch the metadata for an OAuth authorization server.
+
+
Retrieves the metadata from the authorization server's
+
`.well-known/oauth-authorization-server` endpoint, providing endpoint URLs
+
required for the OAuth flow.
+
+
## Parameters
+
+
- `issuer` - Authorization server issuer URL
+
+
## Returns
+
+
- `{:ok, metadata}` - Successfully retrieved authorization server metadata
+
- `{:error, :invalid_metadata}` - Server returned invalid metadata
+
- `{:error, :invalid_issuer}` - Issuer mismatch in metadata
+
- `{:error, any()}` - Other error fetching metadata
+
"""
+
@spec get_authorization_server_metadata(String.t()) ::
+
{:ok, authorization_metadata()} | {:error, any()}
+
def get_authorization_server_metadata(issuer) do
+
"#{issuer}/.well-known/oauth-authorization-server"
+
|> Req.get()
+
|> case do
+
{:ok,
+
%{
+
body: %{
+
"issuer" => metadata_issuer,
+
"pushed_authorization_request_endpoint" => par_endpoint,
+
"token_endpoint" => token_endpoint,
+
"authorization_endpoint" => authorization_endpoint
+
}
+
}} ->
+
if issuer != metadata_issuer do
+
{:error, :invaild_issuer}
+
else
+
{:ok,
+
%{
+
issuer: metadata_issuer,
+
par_endpoint: par_endpoint,
+
token_endpoint: token_endpoint,
+
authorization_endpoint: authorization_endpoint
+
}}
+
end
+
+
{:ok, _} ->
+
{:error, :invalid_metadata}
+
+
err ->
+
err
+
end
+
end
+
+
@spec send_oauth_dpop_request(Req.Request.t(), JOSE.JWK.t(), String.t() | nil) ::
+
{:ok, map(), String.t()} | {:error, any(), String.t()}
+
def send_oauth_dpop_request(request, dpop_key, nonce \\ nil) do
+
dpop_token = create_dpop_token(dpop_key, request, nonce)
+
+
request
+
|> Req.Request.put_header("dpop", dpop_token)
+
|> Req.request()
+
|> case do
+
{:ok, resp} ->
+
dpop_nonce =
+
case resp.headers["dpop-nonce"] do
+
[new_nonce | _] -> new_nonce
+
_ -> nonce
+
end
+
+
cond do
+
resp.status == 200 ->
+
{:ok, resp.body, dpop_nonce}
+
+
resp.body["error"] === "use_dpop_nonce" ->
+
dpop_token = create_dpop_token(dpop_key, request, dpop_nonce)
+
+
request
+
|> Req.Request.put_header("dpop", dpop_token)
+
|> Req.request()
+
|> case do
+
{:ok, %{status: 200, body: body}} ->
+
{:ok, body, dpop_nonce}
+
+
{:ok, %{body: %{"error" => error, "error_description" => error_description}}} ->
+
{:error, {:oauth_error, error, error_description}, dpop_nonce}
+
+
{:ok, _} ->
+
{:error, :unexpected_response, dpop_nonce}
+
+
{:error, err} ->
+
{:error, err, dpop_nonce}
+
end
+
+
true ->
+
{:error, {:oauth_error, resp.body["error"], resp.body["error_description"]},
+
dpop_nonce}
+
end
+
+
{:error, err} ->
+
{:error, err, nonce}
+
end
+
end
+
+
@spec request_protected_dpop_resource(
+
Req.Request.t(),
+
String.t(),
+
String.t(),
+
JOSE.JWK.t(),
+
String.t() | nil
+
) :: {:ok, Req.Response.t(), String.t() | nil} | {:error, any()}
+
def request_protected_dpop_resource(request, issuer, access_token, dpop_key, nonce \\ nil) do
+
access_token_hash = :crypto.hash(:sha256, access_token) |> Base.url_encode64(padding: false)
+
# access_token_hash = Base.url_encode64(access_token, padding: false)
+
+
dpop_token =
+
create_dpop_token(dpop_key, request, nonce, %{iss: issuer, ath: access_token_hash})
+
+
request
+
|> Req.Request.put_header("dpop", dpop_token)
+
|> Req.request()
+
|> case do
+
{:ok, resp} ->
+
dpop_nonce =
+
case resp.headers["dpop-nonce"] do
+
[new_nonce | _] -> new_nonce
+
_ -> nonce
+
end
+
+
www_authenticate = Req.Response.get_header(resp, "www-authenticate")
+
+
www_dpop_problem =
+
www_authenticate != [] && String.starts_with?(Enum.at(www_authenticate, 0), "DPoP")
+
+
if resp.status != 401 || !www_dpop_problem do
+
{:ok, resp, dpop_nonce}
+
else
+
dpop_token =
+
create_dpop_token(dpop_key, request, dpop_nonce, %{
+
iss: issuer,
+
ath: access_token_hash
+
})
+
+
request
+
|> Req.Request.put_header("dpop", dpop_token)
+
|> Req.request()
+
|> case do
+
{:ok, resp} ->
+
dpop_nonce =
+
case resp.headers["dpop-nonce"] do
+
[new_nonce | _] -> new_nonce
+
_ -> dpop_nonce
+
end
+
+
{:ok, resp, dpop_nonce}
+
+
err ->
+
err
+
end
+
end
+
+
err ->
+
err
+
end
+
end
+
+
@spec create_client_assertion(JOSE.JWK.t(), String.t(), String.t()) :: String.t()
+
def create_client_assertion(jwk, client_id, issuer) do
+
iat = System.os_time(:second)
+
jti = random_b64(20)
+
jws = %{"alg" => "ES256", "kid" => jwk.fields["kid"]}
+
+
jwt = %{
+
iss: client_id,
+
sub: client_id,
+
aud: issuer,
+
jti: jti,
+
iat: iat,
+
exp: iat + 60
+
}
+
+
JOSE.JWT.sign(jwk, jws, jwt)
+
|> JOSE.JWS.compact()
+
|> elem(1)
+
end
+
+
@spec create_dpop_token(JOSE.JWK.t(), Req.Request.t(), any(), map()) :: String.t()
+
def create_dpop_token(jwk, request, nonce \\ nil, attrs \\ %{}) do
+
iat = System.os_time(:second)
+
jti = random_b64(20)
+
{_, public_jwk} = JOSE.JWK.to_public_map(jwk)
+
jws = %{"alg" => "ES256", "typ" => "dpop+jwt", "jwk" => public_jwk}
+
[request_url | _] = request.url |> to_string() |> String.split("?")
+
+
jwt =
+
Map.merge(attrs, %{
+
jti: jti,
+
htm: atom_to_upcase_string(request.method),
+
htu: request_url,
+
iat: iat
+
})
+
|> then(fn m ->
+
if nonce, do: Map.put(m, :nonce, nonce), else: m
+
end)
+
+
JOSE.JWT.sign(jwk, jws, jwt)
+
|> JOSE.JWS.compact()
+
|> elem(1)
+
end
+
+
@doc false
+
@spec atom_to_upcase_string(atom()) :: String.t()
+
def atom_to_upcase_string(atom) do
+
atom |> to_string() |> String.upcase()
+
end
+
end
+40
lib/atex/peri.ex
···
+
defmodule Atex.Peri do
+
@moduledoc """
+
Custom validators for Peri, for use within atex.
+
"""
+
+
def uri, do: {:custom, &validate_uri/1}
+
def did, do: {:string, {:regex, ~r/^did:[a-z]+:[a-zA-Z0-9._:%-]*[a-zA-Z0-9._-]$/}}
+
+
defp validate_uri(uri) when is_binary(uri) do
+
case URI.new(uri) do
+
{:ok, _} -> :ok
+
{:error, _} -> {:error, "must be a valid URI", [uri: uri]}
+
end
+
end
+
+
defp validate_uri(uri), do: {:error, "must be a valid URI", [uri: uri]}
+
+
def validate_map(value, schema, extra_keys_schema) when is_map(value) and is_map(schema) do
+
extra_keys =
+
Enum.reduce(Map.keys(schema), MapSet.new(Map.keys(value)), fn key, acc ->
+
acc |> MapSet.delete(key) |> MapSet.delete(to_string(key))
+
end)
+
+
extra_data =
+
value
+
|> Enum.filter(fn {key, _} -> MapSet.member?(extra_keys, key) end)
+
|> Map.new()
+
+
with {:ok, schema_data} <- Peri.validate(schema, value),
+
{:ok, extra_data} <- Peri.validate(extra_keys_schema, extra_data) do
+
{:ok, Map.merge(schema_data, extra_data)}
+
else
+
{:error, %Peri.Error{} = err} -> {:error, [err]}
+
e -> e
+
end
+
end
+
+
def validate_map(value, _schema, _extra_keys_schema),
+
do: {:error, "must be a map", [value: value]}
+
end
+189
lib/atex/tid.ex
···
+
defmodule Atex.TID do
+
@moduledoc """
+
Struct and helper functions for dealing with AT Protocol TIDs (Timestamp
+
Identifiers), a 13-character string representation of a 64-bit number
+
comprised of a Unix timestamp (in microsecond precision) and a random "clock
+
identifier" to help avoid collisions.
+
+
ATProto spec: https://atproto.com/specs/tid
+
+
TID strings are always 13 characters long. All bits in the 64-bit number are
+
encoded, essentially meaning that the string is padded with "2" if necessary,
+
(the 0th character in the base32-sortable alphabet).
+
"""
+
import Bitwise
+
alias Atex.Base32Sortable
+
use TypedStruct
+
+
@re ~r/^[234567abcdefghij][234567abcdefghijklmnopqrstuvwxyz]{12}$/
+
+
@typedoc """
+
A Unix timestamp representing when the TID was created.
+
"""
+
@type timestamp() :: integer()
+
+
@typedoc """
+
An integer to be used for the lower 10 bits of the TID.
+
"""
+
@type clock_id() :: 0..1023
+
+
typedstruct enforce: true do
+
field :timestamp, timestamp()
+
field :clock_id, clock_id()
+
end
+
+
@doc """
+
Returns a TID for the current moment in time, along with a random clock ID.
+
"""
+
@spec now() :: t()
+
def now,
+
do: %__MODULE__{
+
timestamp: DateTime.utc_now(:microsecond) |> DateTime.to_unix(:microsecond),
+
clock_id: gen_clock_id()
+
}
+
+
@doc """
+
Create a new TID from a `DateTime` or an integer representing a Unix timestamp in microseconds.
+
+
If `clock_id` isn't provided, a random one will be generated.
+
"""
+
@spec new(DateTime.t() | integer(), integer() | nil) :: t()
+
def new(source, clock_id \\ nil)
+
+
def new(%DateTime{} = datetime, clock_id),
+
do: %__MODULE__{
+
timestamp: DateTime.to_unix(datetime, :microsecond),
+
clock_id: clock_id || gen_clock_id()
+
}
+
+
def new(unix, clock_id) when is_integer(unix),
+
do: %__MODULE__{timestamp: unix, clock_id: clock_id || gen_clock_id()}
+
+
@doc """
+
Convert a TID struct to an instance of `DateTime`.
+
"""
+
def to_datetime(%__MODULE__{} = tid), do: DateTime.from_unix(tid.timestamp, :microsecond)
+
+
@doc """
+
Generate a random integer to be used as a `clock_id`.
+
"""
+
@spec gen_clock_id() :: clock_id()
+
def gen_clock_id, do: :rand.uniform(1024) - 1
+
+
@doc """
+
Decode a TID string into an `Atex.TID` struct, returning an error if it's invalid.
+
+
## Examples
+
+
Syntactically valid TIDs:
+
+
iex> Atex.TID.decode("3jzfcijpj2z2a")
+
{:ok, %Atex.TID{clock_id: 6, timestamp: 1688137381887007}}
+
+
iex> Atex.TID.decode("7777777777777")
+
{:ok, %Atex.TID{clock_id: 165, timestamp: 5811096293381285}}
+
+
iex> Atex.TID.decode("3zzzzzzzzzzzz")
+
{:ok, %Atex.TID{clock_id: 1023, timestamp: 2251799813685247}}
+
+
iex> Atex.TID.decode("2222222222222")
+
{:ok, %Atex.TID{clock_id: 0, timestamp: 0}}
+
+
Invalid TIDs:
+
+
# not base32
+
iex> Atex.TID.decode("3jzfcijpj2z21")
+
:error
+
iex> Atex.TID.decode("0000000000000")
+
:error
+
+
# case-sensitive
+
iex> Atex.TID.decode("3JZFCIJPJ2Z2A")
+
:error
+
+
# too long/short
+
iex> Atex.TID.decode("3jzfcijpj2z2aa")
+
:error
+
iex> Atex.TID.decode("3jzfcijpj2z2")
+
:error
+
iex> Atex.TID.decode("222")
+
:error
+
+
# legacy dash syntax *not* supported (TTTT-TTT-TTTT-CC)
+
iex> Atex.TID.decode("3jzf-cij-pj2z-2a")
+
:error
+
+
# high bit can't be set
+
iex> Atex.TID.decode("zzzzzzzzzzzzz")
+
:error
+
iex> Atex.TID.decode("kjzfcijpj2z2a")
+
:error
+
+
"""
+
@spec decode(String.t()) :: {:ok, t()} | :error
+
def decode(<<timestamp::binary-size(11), clock_id::binary-size(2)>> = tid) do
+
if match?(tid) do
+
timestamp = Base32Sortable.decode(timestamp)
+
clock_id = Base32Sortable.decode(clock_id)
+
+
{:ok,
+
%__MODULE__{
+
timestamp: timestamp,
+
clock_id: clock_id
+
}}
+
else
+
:error
+
end
+
end
+
+
def decode(_tid), do: :error
+
+
@doc """
+
Encode a TID struct into a string.
+
+
## Examples
+
+
iex> Atex.TID.encode(%Atex.TID{clock_id: 6, timestamp: 1688137381887007})
+
"3jzfcijpj2z2a"
+
+
iex> Atex.TID.encode(%Atex.TID{clock_id: 165, timestamp: 5811096293381285})
+
"7777777777777"
+
+
iex> Atex.TID.encode(%Atex.TID{clock_id: 1023, timestamp: 2251799813685247})
+
"3zzzzzzzzzzzz"
+
+
iex> Atex.TID.encode(%Atex.TID{clock_id: 0, timestamp: 0})
+
"2222222222222"
+
+
"""
+
@spec encode(t()) :: String.t()
+
def encode(%__MODULE__{} = tid) do
+
timestamp = tid.timestamp |> Base32Sortable.encode() |> String.pad_leading(11, "2")
+
clock_id = (tid.clock_id &&& 1023) |> Base32Sortable.encode() |> String.pad_leading(2, "2")
+
timestamp <> clock_id
+
end
+
+
@doc """
+
Check if a given string matches the format for a TID.
+
+
## Examples
+
+
iex> Atex.TID.match?("3jzfcijpj2z2a")
+
true
+
+
iex> Atex.TID.match?("2222222222222")
+
true
+
+
iex> Atex.TID.match?("banana")
+
false
+
+
iex> Atex.TID.match?("kjzfcijpj2z2a")
+
false
+
"""
+
@spec match?(String.t()) :: boolean()
+
def match?(value), do: Regex.match?(@re, value)
+
end
+
+
defimpl String.Chars, for: Atex.TID do
+
def to_string(tid), do: Atex.TID.encode(tid)
+
end
+31
lib/atex/xrpc/client.ex
···
+
defmodule Atex.XRPC.Client do
+
@moduledoc """
+
Behaviour that defines the interface for XRPC clients.
+
+
This behaviour allows different types of clients (login-based, OAuth-based, etc.)
+
to implement authentication and request handling while maintaining a consistent interface.
+
+
Implementations must handle token refresh internally when requests fail due to
+
expired tokens, and return both the result and potentially updated client state.
+
"""
+
+
@type client :: struct()
+
@type request_opts :: keyword()
+
@type request_result :: {:ok, Req.Response.t(), client()} | {:error, any(), client()}
+
+
@doc """
+
Perform an authenticated HTTP GET request on an XRPC resource.
+
+
Implementations should handle token refresh if the request fails due to
+
expired authentication, returning both the response and the (potentially updated) client.
+
"""
+
@callback get(client(), String.t(), request_opts()) :: request_result()
+
+
@doc """
+
Perform an authenticated HTTP POST request on an XRPC resource.
+
+
Implementations should handle token refresh if the request fails due to
+
expired authentication, returning both the response and the (potentially updated) client.
+
"""
+
@callback post(client(), String.t(), request_opts()) :: request_result()
+
end
+148
lib/atex/xrpc/login_client.ex
···
+
defmodule Atex.XRPC.LoginClient do
+
alias Atex.XRPC
+
use TypedStruct
+
+
@behaviour Atex.XRPC.Client
+
+
typedstruct do
+
field :endpoint, String.t(), enforce: true
+
field :access_token, String.t() | nil
+
field :refresh_token, String.t() | nil
+
end
+
+
@doc """
+
Create a new `Atex.XRPC.LoginClient` from an endpoint, and optionally an
+
existing access/refresh token.
+
+
Endpoint should be the base URL of a PDS, or an AppView in the case of
+
unauthenticated requests (like Bluesky's public API), e.g.
+
`https://bsky.social`.
+
"""
+
@spec new(String.t(), String.t() | nil, String.t() | nil) :: t()
+
def new(endpoint, access_token \\ nil, refresh_token \\ nil) do
+
%__MODULE__{endpoint: endpoint, access_token: access_token, refresh_token: refresh_token}
+
end
+
+
@doc """
+
Create a new `Atex.XRPC.LoginClient` by logging in with an `identifier` and
+
`password` to fetch an initial pair of access & refresh tokens.
+
+
Also supports providing a MFA token in the situation that is required.
+
+
Uses `com.atproto.server.createSession` under the hood, so `identifier` can be
+
either a handle or a DID.
+
+
## Examples
+
+
iex> Atex.XRPC.LoginClient.login("https://bsky.social", "example.com", "password123")
+
{:ok, %Atex.XRPC.LoginClient{...}}
+
"""
+
@spec login(String.t(), String.t(), String.t()) :: {:ok, t()} | {:error, any()}
+
@spec login(String.t(), String.t(), String.t(), String.t() | nil) ::
+
{:ok, t()} | {:error, any()}
+
def login(endpoint, identifier, password, auth_factor_token \\ nil) do
+
json =
+
%{identifier: identifier, password: password}
+
|> then(
+
&if auth_factor_token do
+
Map.merge(&1, %{authFactorToken: auth_factor_token})
+
else
+
&1
+
end
+
)
+
+
response = XRPC.unauthed_post(endpoint, "com.atproto.server.createSession", json: json)
+
+
case response do
+
{:ok, %{body: %{"accessJwt" => access_token, "refreshJwt" => refresh_token}}} ->
+
{:ok, new(endpoint, access_token, refresh_token)}
+
+
err ->
+
err
+
end
+
end
+
+
@doc """
+
Request a new `refresh_token` for the given client.
+
"""
+
@spec refresh(t()) :: {:ok, t()} | {:error, any()}
+
def refresh(%__MODULE__{endpoint: endpoint, refresh_token: refresh_token} = client) do
+
request =
+
Req.new(method: :post, url: XRPC.url(endpoint, "com.atproto.server.refreshSession"))
+
|> put_auth(refresh_token)
+
+
case Req.request(request) do
+
{:ok, %{body: %{"accessJwt" => access_token, "refreshJwt" => refresh_token}}} ->
+
{:ok, %{client | access_token: access_token, refresh_token: refresh_token}}
+
+
{:ok, response} ->
+
{:error, response}
+
+
err ->
+
err
+
end
+
end
+
+
@impl true
+
def get(%__MODULE__{} = client, resource, opts \\ []) do
+
request(client, opts ++ [method: :get, url: XRPC.url(client.endpoint, resource)])
+
end
+
+
@impl true
+
def post(%__MODULE__{} = client, resource, opts \\ []) do
+
request(client, opts ++ [method: :post, url: XRPC.url(client.endpoint, resource)])
+
end
+
+
@spec request(t(), keyword()) :: {:ok, Req.Response.t(), t()} | {:error, any()}
+
defp request(client, opts) do
+
with {:ok, client} <- validate_client(client) do
+
request = opts |> Req.new() |> put_auth(client.access_token)
+
+
case Req.request(request) do
+
{:ok, %{status: 200} = response} ->
+
{:ok, response, client}
+
+
{:ok, response} ->
+
handle_failure(client, response, request)
+
+
err ->
+
err
+
end
+
end
+
end
+
+
@spec handle_failure(t(), Req.Response.t(), Req.Request.t()) ::
+
{:ok, Req.Response.t(), t()} | {:error, any()}
+
defp handle_failure(client, response, request) do
+
IO.inspect(response, label: "got failure")
+
+
if auth_error?(response.body) and client.refresh_token do
+
case refresh(client) do
+
{:ok, client} ->
+
case Req.request(put_auth(request, client.access_token)) do
+
{:ok, %{status: 200} = response} -> {:ok, response, client}
+
{:ok, response} -> {:error, response}
+
err -> err
+
end
+
+
err ->
+
err
+
end
+
else
+
{:error, response}
+
end
+
end
+
+
@spec validate_client(t()) :: {:ok, t()} | {:error, any()}
+
defp validate_client(%__MODULE__{access_token: nil}), do: {:error, :no_token}
+
defp validate_client(%__MODULE__{} = client), do: {:ok, client}
+
+
@spec auth_error?(body :: Req.Response.t()) :: boolean()
+
defp auth_error?(%{status: status}) when status in [401, 403], do: true
+
defp auth_error?(%{body: %{"error" => "InvalidToken"}}), do: true
+
defp auth_error?(_response), do: false
+
+
@spec put_auth(Req.Request.t(), String.t()) :: Req.Request.t()
+
defp put_auth(request, token),
+
do: Req.Request.put_header(request, "authorization", "Bearer #{token}")
+
end
+228
lib/atex/xrpc/oauth_client.ex
···
+
defmodule Atex.XRPC.OAuthClient do
+
alias Atex.OAuth
+
alias Atex.XRPC
+
use TypedStruct
+
+
@behaviour Atex.XRPC.Client
+
+
typedstruct enforce: true do
+
field :endpoint, String.t()
+
field :issuer, String.t()
+
field :access_token, String.t()
+
field :refresh_token, String.t()
+
field :did, String.t()
+
field :expires_at, NaiveDateTime.t()
+
field :dpop_nonce, String.t() | nil, enforce: false
+
field :dpop_key, JOSE.JWK.t()
+
end
+
+
@doc """
+
Create a new OAuthClient struct.
+
"""
+
@spec new(
+
String.t(),
+
String.t(),
+
String.t(),
+
String.t(),
+
NaiveDateTime.t(),
+
JOSE.JWK.t(),
+
String.t() | nil
+
) :: t()
+
def new(endpoint, did, access_token, refresh_token, expires_at, dpop_key, dpop_nonce) do
+
{:ok, issuer} = OAuth.get_authorization_server(endpoint)
+
+
%__MODULE__{
+
endpoint: endpoint,
+
issuer: issuer,
+
access_token: access_token,
+
refresh_token: refresh_token,
+
did: did,
+
expires_at: expires_at,
+
dpop_nonce: dpop_nonce,
+
dpop_key: dpop_key
+
}
+
end
+
+
@doc """
+
Create an OAuthClient struct from a `Plug.Conn`.
+
+
Requires the conn to have passed through `Plug.Session` and
+
`Plug.Conn.fetch_session/2` so that the session can be acquired and have the
+
`atex_oauth` key fetched from it.
+
+
Returns `:error` if the state is missing or is not the expected shape.
+
"""
+
@spec from_conn(Plug.Conn.t()) :: {:ok, t()} | :error
+
def from_conn(%Plug.Conn{} = conn) do
+
oauth_state = Plug.Conn.get_session(conn, :atex_oauth)
+
+
case oauth_state do
+
%{
+
access_token: access_token,
+
refresh_token: refresh_token,
+
did: did,
+
pds: pds,
+
expires_at: expires_at,
+
dpop_nonce: dpop_nonce,
+
dpop_key: dpop_key
+
} ->
+
{:ok, new(pds, did, access_token, refresh_token, expires_at, dpop_key, dpop_nonce)}
+
+
_ ->
+
:error
+
end
+
end
+
+
@doc """
+
Updates a `Plug.Conn` session with the latest values from the client.
+
+
Ideally should be called at the end of routes where XRPC calls occur, in case
+
the client has transparently refreshed, so that the user is always up to date.
+
"""
+
@spec update_plug(Plug.Conn.t(), t()) :: Plug.Conn.t()
+
def update_plug(%Plug.Conn{} = conn, %__MODULE__{} = client) do
+
Plug.Conn.put_session(conn, :atex_oauth, %{
+
access_token: client.access_token,
+
refresh_token: client.refresh_token,
+
did: client.did,
+
pds: client.endpoint,
+
expires_at: client.expires_at,
+
dpop_nonce: client.dpop_nonce,
+
dpop_key: client.dpop_key
+
})
+
end
+
+
@doc """
+
Ask the client's OAuth server for a new set of auth tokens.
+
+
You shouldn't need to call this manually for the most part, the client does
+
it's best to refresh automatically when it needs to.
+
"""
+
@spec refresh(t()) :: {:ok, t()} | {:error, any()}
+
def refresh(%__MODULE__{} = client) do
+
with {:ok, authz_server} <- OAuth.get_authorization_server(client.endpoint),
+
{:ok, %{token_endpoint: token_endpoint}} <-
+
OAuth.get_authorization_server_metadata(authz_server) do
+
case OAuth.refresh_token(
+
client.refresh_token,
+
client.dpop_key,
+
client.issuer,
+
token_endpoint
+
) do
+
{:ok, tokens, nonce} ->
+
{:ok,
+
%{
+
client
+
| access_token: tokens.access_token,
+
refresh_token: tokens.refresh_token,
+
dpop_nonce: nonce
+
}}
+
+
err ->
+
err
+
end
+
end
+
end
+
+
@doc """
+
See `Atex.XRPC.get/3`.
+
"""
+
@impl true
+
def get(%__MODULE__{} = client, resource, opts \\ []) do
+
request(client, opts ++ [method: :get, url: XRPC.url(client.endpoint, resource)])
+
end
+
+
@doc """
+
See `Atex.XRPC.post/3`.
+
"""
+
@impl true
+
def post(%__MODULE__{} = client, resource, opts \\ []) do
+
request(client, opts ++ [method: :post, url: XRPC.url(client.endpoint, resource)])
+
end
+
+
@spec request(t(), keyword()) :: {:ok, Req.Response.t(), t()} | {:error, any(), any()}
+
defp request(client, opts) do
+
# Preemptively refresh token if it's about to expire
+
with {:ok, client} <- maybe_refresh(client) do
+
request = opts |> Req.new() |> put_auth(client.access_token)
+
+
case OAuth.request_protected_dpop_resource(
+
request,
+
client.issuer,
+
client.access_token,
+
client.dpop_key,
+
client.dpop_nonce
+
) do
+
{:ok, %{status: 200} = response, nonce} ->
+
client = %{client | dpop_nonce: nonce}
+
{:ok, response, client}
+
+
{:ok, response, nonce} ->
+
client = %{client | dpop_nonce: nonce}
+
handle_failure(client, response, request)
+
+
err ->
+
err
+
end
+
end
+
end
+
+
@spec handle_failure(t(), Req.Response.t(), Req.Request.t()) ::
+
{:ok, Req.Response.t(), t()} | {:error, any(), t()}
+
defp handle_failure(client, response, request) do
+
IO.inspect(response, label: "got failure")
+
+
if auth_error?(response.body) and client.refresh_token do
+
case refresh(client) do
+
{:ok, client} ->
+
case OAuth.request_protected_dpop_resource(
+
request,
+
client.issuer,
+
client.access_token,
+
client.dpop_key,
+
client.dpop_nonce
+
) do
+
{:ok, %{status: 200} = response, nonce} ->
+
{:ok, response, %{client | dpop_nonce: nonce}}
+
+
{:ok, response, nonce} ->
+
{:error, response, %{client | dpop_nonce: nonce}}
+
+
{:error, err} ->
+
{:error, err, client}
+
end
+
+
err ->
+
err
+
end
+
else
+
{:error, response, client}
+
end
+
end
+
+
@spec maybe_refresh(t(), integer()) :: {:ok, t()} | {:error, any()}
+
defp maybe_refresh(%__MODULE__{expires_at: expires_at} = client, buffer_minutes \\ 5) do
+
if token_expiring_soon?(expires_at, buffer_minutes) do
+
refresh(client)
+
else
+
{:ok, client}
+
end
+
end
+
+
@spec token_expiring_soon?(NaiveDateTime.t(), integer()) :: boolean()
+
defp token_expiring_soon?(expires_at, buffer_minutes) do
+
now = NaiveDateTime.utc_now()
+
expiry_threshold = NaiveDateTime.add(now, buffer_minutes * 60, :second)
+
+
NaiveDateTime.compare(expires_at, expiry_threshold) in [:lt, :eq]
+
end
+
+
@spec auth_error?(body :: Req.Response.t()) :: boolean()
+
defp auth_error?(%{status: status}) when status in [401, 403], do: true
+
defp auth_error?(%{body: %{"error" => "InvalidToken"}}), do: true
+
defp auth_error?(_response), do: false
+
+
@spec put_auth(Req.Request.t(), String.t()) :: Req.Request.t()
+
defp put_auth(request, token),
+
do: Req.Request.put_header(request, "authorization", "DPoP #{token}")
+
end
+210
lib/atex/xrpc.ex
···
+
defmodule Atex.XRPC do
+
@moduledoc """
+
XRPC client module for AT Protocol RPC calls.
+
+
This module provides both authenticated and unauthenticated access to AT Protocol
+
XRPC endpoints. The authenticated functions (`get/3`, `post/3`) work with any
+
client that implements the `Atex.XRPC.Client`.
+
+
## Example usage
+
+
# Login-based client
+
{:ok, client} = Atex.XRPC.LoginClient.login("https://bsky.social", "user.bsky.social", "password")
+
{:ok, response, client} = Atex.XRPC.get(client, "app.bsky.actor.getProfile", params: [actor: "user.bsky.social"])
+
+
# OAuth-based client
+
{:ok, oauth_client} = Atex.XRPC.OAuthClient.from_conn(conn)
+
{:ok, response, oauth_client} = Atex.XRPC.get(oauth_client, "app.bsky.actor.getProfile", params: [actor: "user.bsky.social"])
+
+
## Unauthenticated requests
+
+
Unauthenticated functions (`unauthed_get/3`, `unauthed_post/3`) do not require a client
+
and work directly with endpoints:
+
+
{:ok, response} = Atex.XRPC.unauthed_get("https://bsky.social", "com.atproto.sync.getHead", params: [did: "did:plc:..."])
+
"""
+
+
alias Atex.XRPC.Client
+
+
@doc """
+
Perform a HTTP GET on a XRPC resource. Called a "query" in lexicons.
+
+
Accepts any client that implements `Atex.XRPC.Client` and returns
+
both the response and the (potentially updated) client.
+
+
Can be called either with the XRPC operation name as a string, or with a lexicon
+
struct (generated via `deflexicon`) for type safety and automatic parameter/response handling.
+
+
When using a lexicon struct, the response body will be automatically converted to the
+
corresponding type if an Output struct exists for the lexicon.
+
+
## Examples
+
+
# Using string XRPC name
+
{:ok, response, client} =
+
Atex.XRPC.get(client, "app.bsky.actor.getProfile", params: [actor: "ovyerus.com"])
+
+
# Using lexicon struct with typed construction
+
{:ok, response, client} =
+
Atex.XRPC.get(client, %App.Bsky.Actor.GetProfile{
+
params: %App.Bsky.Actor.GetProfile.Params{actor: "ovyerus.com"}
+
})
+
"""
+
@spec get(Client.client(), String.t() | struct(), keyword()) ::
+
{:ok, Req.Response.t(), Client.client()}
+
| {:error, any(), Client.client()}
+
def get(client, name, opts \\ [])
+
+
def get(client, name, opts) when is_binary(name) do
+
client.__struct__.get(client, name, opts)
+
end
+
+
def get(client, %{__struct__: module} = query, opts) do
+
opts = if Map.get(query, :params), do: Keyword.put(opts, :params, query.params), else: opts
+
output_struct = Module.concat(module, Output)
+
output_exists = Code.ensure_loaded?(output_struct)
+
+
case client.__struct__.get(client, module.id(), opts) do
+
{:ok, %{status: 200} = response, client} ->
+
if output_exists do
+
case output_struct.from_json(response.body) do
+
{:ok, output} ->
+
{:ok, %{response | body: output}, client}
+
+
err ->
+
err
+
end
+
else
+
{:ok, response, client}
+
end
+
+
{:ok, _, _} = ok ->
+
ok
+
+
err ->
+
err
+
end
+
end
+
+
@doc """
+
Perform a HTTP POST on a XRPC resource. Called a "procedure" in lexicons.
+
+
Accepts any client that implements `Atex.XRPC.Client` and returns both the
+
response and the (potentially updated) client.
+
+
Can be called either with the XRPC operation name as a string, or with a
+
lexicon struct (generated via `deflexicon`) for type safety and automatic
+
input/parameter mapping.
+
+
When using a lexicon struct, the response body will be automatically converted
+
to the corresponding type if an Output struct exists for the lexicon.
+
+
## Examples
+
+
# Using string XRPC name
+
{:ok, response, client} =
+
Atex.XRPC.post(
+
client,
+
"com.atproto.repo.createRecord",
+
json: %{
+
repo: "did:plc:...",
+
collection: "app.bsky.feed.post",
+
rkey: Atex.TID.now() |> to_string(),
+
record: %{
+
text: "Hello World",
+
createdAt: NaiveDateTime.to_iso8601(NaiveDateTime.utc_now())
+
}
+
}
+
)
+
+
# Using lexicon struct with typed construction
+
{:ok, response, client} =
+
Atex.XRPC.post(client, %Com.Atproto.Repo.CreateRecord{
+
input: %Com.Atproto.Repo.CreateRecord.Input{
+
repo: "did:plc:...",
+
collection: "app.bsky.feed.post",
+
rkey: Atex.TID.now() |> to_string(),
+
record: %App.Bsky.Feed.Post{
+
text: "Hello World!",
+
createdAt: NaiveDateTime.to_iso8601(NaiveDateTime.utc_now())
+
}
+
}
+
})
+
"""
+
@spec post(Client.client(), String.t() | struct(), keyword()) ::
+
{:ok, Req.Response.t(), Client.client()}
+
| {:error, any(), Client.client()}
+
def post(client, name, opts \\ [])
+
+
def post(client, name, opts) when is_binary(name) do
+
client.__struct__.post(client, name, opts)
+
end
+
+
def post(client, %{__struct__: module} = procedure, opts) do
+
opts =
+
opts
+
|> then(
+
&if Map.get(procedure, :params), do: Keyword.put(&1, :params, procedure.params), else: &1
+
)
+
|> then(
+
&cond do
+
Map.get(procedure, :input) -> Keyword.put(&1, :json, procedure.input)
+
Map.get(procedure, :raw_input) -> Keyword.put(&1, :body, procedure.raw_input)
+
true -> &1
+
end
+
)
+
+
output_struct = Module.concat(module, Output)
+
output_exists = Code.ensure_loaded?(output_struct)
+
+
case client.__struct__.post(client, module.id(), opts) do
+
{:ok, %{status: 200} = response, client} ->
+
if output_exists do
+
case output_struct.from_json(response.body) do
+
{:ok, output} ->
+
{:ok, %{response | body: output}, client}
+
+
err ->
+
err
+
end
+
else
+
{:ok, response, client}
+
end
+
+
{:ok, _, _} = ok ->
+
ok
+
+
err ->
+
err
+
end
+
end
+
+
@doc """
+
Like `get/3` but is unauthenticated by default.
+
"""
+
@spec unauthed_get(String.t(), String.t(), keyword()) ::
+
{:ok, Req.Response.t()} | {:error, any()}
+
def unauthed_get(endpoint, name, opts \\ []) do
+
Req.get(url(endpoint, name), opts)
+
end
+
+
@doc """
+
Like `post/3` but is unauthenticated by default.
+
"""
+
@spec unauthed_post(String.t(), String.t(), keyword()) ::
+
{:ok, Req.Response.t()} | {:error, any()}
+
def unauthed_post(endpoint, name, opts \\ []) do
+
Req.post(url(endpoint, name), opts)
+
end
+
+
@doc """
+
Create an XRPC url based on an endpoint and a resource name.
+
+
## Example
+
+
iex> Atex.XRPC.url("https://bsky.app", "app.bsky.actor.getProfile")
+
"https://bsky.app/xrpc/app.bsky.actor.getProfile"
+
"""
+
@spec url(String.t(), String.t()) :: String.t()
+
def url(endpoint, resource) when is_binary(endpoint), do: "#{endpoint}/xrpc/#{resource}"
+
end
-2
lib/atex.ex
···
-
defmodule Atex do
-
end
+73
lib/atproto/com/atproto/admin/defs.ex
···
+
defmodule Com.Atproto.Admin.Defs do
+
use Atex.Lexicon
+
+
deflexicon(%{
+
"defs" => %{
+
"accountView" => %{
+
"properties" => %{
+
"deactivatedAt" => %{"format" => "datetime", "type" => "string"},
+
"did" => %{"format" => "did", "type" => "string"},
+
"email" => %{"type" => "string"},
+
"emailConfirmedAt" => %{"format" => "datetime", "type" => "string"},
+
"handle" => %{"format" => "handle", "type" => "string"},
+
"indexedAt" => %{"format" => "datetime", "type" => "string"},
+
"inviteNote" => %{"type" => "string"},
+
"invitedBy" => %{
+
"ref" => "com.atproto.server.defs#inviteCode",
+
"type" => "ref"
+
},
+
"invites" => %{
+
"items" => %{
+
"ref" => "com.atproto.server.defs#inviteCode",
+
"type" => "ref"
+
},
+
"type" => "array"
+
},
+
"invitesDisabled" => %{"type" => "boolean"},
+
"relatedRecords" => %{
+
"items" => %{"type" => "unknown"},
+
"type" => "array"
+
},
+
"threatSignatures" => %{
+
"items" => %{"ref" => "#threatSignature", "type" => "ref"},
+
"type" => "array"
+
}
+
},
+
"required" => ["did", "handle", "indexedAt"],
+
"type" => "object"
+
},
+
"repoBlobRef" => %{
+
"properties" => %{
+
"cid" => %{"format" => "cid", "type" => "string"},
+
"did" => %{"format" => "did", "type" => "string"},
+
"recordUri" => %{"format" => "at-uri", "type" => "string"}
+
},
+
"required" => ["did", "cid"],
+
"type" => "object"
+
},
+
"repoRef" => %{
+
"properties" => %{"did" => %{"format" => "did", "type" => "string"}},
+
"required" => ["did"],
+
"type" => "object"
+
},
+
"statusAttr" => %{
+
"properties" => %{
+
"applied" => %{"type" => "boolean"},
+
"ref" => %{"type" => "string"}
+
},
+
"required" => ["applied"],
+
"type" => "object"
+
},
+
"threatSignature" => %{
+
"properties" => %{
+
"property" => %{"type" => "string"},
+
"value" => %{"type" => "string"}
+
},
+
"required" => ["property", "value"],
+
"type" => "object"
+
}
+
},
+
"id" => "com.atproto.admin.defs",
+
"lexicon" => 1
+
})
+
end
+22
lib/atproto/com/atproto/admin/deleteAccount.ex
···
+
defmodule Com.Atproto.Admin.DeleteAccount do
+
use Atex.Lexicon
+
+
deflexicon(%{
+
"defs" => %{
+
"main" => %{
+
"description" => "Delete a user account as an administrator.",
+
"input" => %{
+
"encoding" => "application/json",
+
"schema" => %{
+
"properties" => %{"did" => %{"format" => "did", "type" => "string"}},
+
"required" => ["did"],
+
"type" => "object"
+
}
+
},
+
"type" => "procedure"
+
}
+
},
+
"id" => "com.atproto.admin.deleteAccount",
+
"lexicon" => 1
+
})
+
end
+29
lib/atproto/com/atproto/admin/disableAccountInvites.ex
···
+
defmodule Com.Atproto.Admin.DisableAccountInvites do
+
use Atex.Lexicon
+
+
deflexicon(%{
+
"defs" => %{
+
"main" => %{
+
"description" =>
+
"Disable an account from receiving new invite codes, but does not invalidate existing codes.",
+
"input" => %{
+
"encoding" => "application/json",
+
"schema" => %{
+
"properties" => %{
+
"account" => %{"format" => "did", "type" => "string"},
+
"note" => %{
+
"description" => "Optional reason for disabled invites.",
+
"type" => "string"
+
}
+
},
+
"required" => ["account"],
+
"type" => "object"
+
}
+
},
+
"type" => "procedure"
+
}
+
},
+
"id" => "com.atproto.admin.disableAccountInvites",
+
"lexicon" => 1
+
})
+
end
+25
lib/atproto/com/atproto/admin/disableInviteCodes.ex
···
+
defmodule Com.Atproto.Admin.DisableInviteCodes do
+
use Atex.Lexicon
+
+
deflexicon(%{
+
"defs" => %{
+
"main" => %{
+
"description" =>
+
"Disable some set of codes and/or all codes associated with a set of users.",
+
"input" => %{
+
"encoding" => "application/json",
+
"schema" => %{
+
"properties" => %{
+
"accounts" => %{"items" => %{"type" => "string"}, "type" => "array"},
+
"codes" => %{"items" => %{"type" => "string"}, "type" => "array"}
+
},
+
"type" => "object"
+
}
+
},
+
"type" => "procedure"
+
}
+
},
+
"id" => "com.atproto.admin.disableInviteCodes",
+
"lexicon" => 1
+
})
+
end
+28
lib/atproto/com/atproto/admin/enableAccountInvites.ex
···
+
defmodule Com.Atproto.Admin.EnableAccountInvites do
+
use Atex.Lexicon
+
+
deflexicon(%{
+
"defs" => %{
+
"main" => %{
+
"description" => "Re-enable an account's ability to receive invite codes.",
+
"input" => %{
+
"encoding" => "application/json",
+
"schema" => %{
+
"properties" => %{
+
"account" => %{"format" => "did", "type" => "string"},
+
"note" => %{
+
"description" => "Optional reason for enabled invites.",
+
"type" => "string"
+
}
+
},
+
"required" => ["account"],
+
"type" => "object"
+
}
+
},
+
"type" => "procedure"
+
}
+
},
+
"id" => "com.atproto.admin.enableAccountInvites",
+
"lexicon" => 1
+
})
+
end
+26
lib/atproto/com/atproto/admin/getAccountInfo.ex
···
+
defmodule Com.Atproto.Admin.GetAccountInfo do
+
use Atex.Lexicon
+
+
deflexicon(%{
+
"defs" => %{
+
"main" => %{
+
"description" => "Get details about an account.",
+
"output" => %{
+
"encoding" => "application/json",
+
"schema" => %{
+
"ref" => "com.atproto.admin.defs#accountView",
+
"type" => "ref"
+
}
+
},
+
"parameters" => %{
+
"properties" => %{"did" => %{"format" => "did", "type" => "string"}},
+
"required" => ["did"],
+
"type" => "params"
+
},
+
"type" => "query"
+
}
+
},
+
"id" => "com.atproto.admin.getAccountInfo",
+
"lexicon" => 1
+
})
+
end
+40
lib/atproto/com/atproto/admin/getAccountInfos.ex
···
+
defmodule Com.Atproto.Admin.GetAccountInfos do
+
use Atex.Lexicon
+
+
deflexicon(%{
+
"defs" => %{
+
"main" => %{
+
"description" => "Get details about some accounts.",
+
"output" => %{
+
"encoding" => "application/json",
+
"schema" => %{
+
"properties" => %{
+
"infos" => %{
+
"items" => %{
+
"ref" => "com.atproto.admin.defs#accountView",
+
"type" => "ref"
+
},
+
"type" => "array"
+
}
+
},
+
"required" => ["infos"],
+
"type" => "object"
+
}
+
},
+
"parameters" => %{
+
"properties" => %{
+
"dids" => %{
+
"items" => %{"format" => "did", "type" => "string"},
+
"type" => "array"
+
}
+
},
+
"required" => ["dids"],
+
"type" => "params"
+
},
+
"type" => "query"
+
}
+
},
+
"id" => "com.atproto.admin.getAccountInfos",
+
"lexicon" => 1
+
})
+
end
+48
lib/atproto/com/atproto/admin/getInviteCodes.ex
···
+
defmodule Com.Atproto.Admin.GetInviteCodes do
+
use Atex.Lexicon
+
+
deflexicon(%{
+
"defs" => %{
+
"main" => %{
+
"description" => "Get an admin view of invite codes.",
+
"output" => %{
+
"encoding" => "application/json",
+
"schema" => %{
+
"properties" => %{
+
"codes" => %{
+
"items" => %{
+
"ref" => "com.atproto.server.defs#inviteCode",
+
"type" => "ref"
+
},
+
"type" => "array"
+
},
+
"cursor" => %{"type" => "string"}
+
},
+
"required" => ["codes"],
+
"type" => "object"
+
}
+
},
+
"parameters" => %{
+
"properties" => %{
+
"cursor" => %{"type" => "string"},
+
"limit" => %{
+
"default" => 100,
+
"maximum" => 500,
+
"minimum" => 1,
+
"type" => "integer"
+
},
+
"sort" => %{
+
"default" => "recent",
+
"knownValues" => ["recent", "usage"],
+
"type" => "string"
+
}
+
},
+
"type" => "params"
+
},
+
"type" => "query"
+
}
+
},
+
"id" => "com.atproto.admin.getInviteCodes",
+
"lexicon" => 1
+
})
+
end
+48
lib/atproto/com/atproto/admin/getSubjectStatus.ex
···
+
defmodule Com.Atproto.Admin.GetSubjectStatus do
+
use Atex.Lexicon
+
+
deflexicon(%{
+
"defs" => %{
+
"main" => %{
+
"description" =>
+
"Get the service-specific admin status of a subject (account, record, or blob).",
+
"output" => %{
+
"encoding" => "application/json",
+
"schema" => %{
+
"properties" => %{
+
"deactivated" => %{
+
"ref" => "com.atproto.admin.defs#statusAttr",
+
"type" => "ref"
+
},
+
"subject" => %{
+
"refs" => [
+
"com.atproto.admin.defs#repoRef",
+
"com.atproto.repo.strongRef",
+
"com.atproto.admin.defs#repoBlobRef"
+
],
+
"type" => "union"
+
},
+
"takedown" => %{
+
"ref" => "com.atproto.admin.defs#statusAttr",
+
"type" => "ref"
+
}
+
},
+
"required" => ["subject"],
+
"type" => "object"
+
}
+
},
+
"parameters" => %{
+
"properties" => %{
+
"blob" => %{"format" => "cid", "type" => "string"},
+
"did" => %{"format" => "did", "type" => "string"},
+
"uri" => %{"format" => "at-uri", "type" => "string"}
+
},
+
"type" => "params"
+
},
+
"type" => "query"
+
}
+
},
+
"id" => "com.atproto.admin.getSubjectStatus",
+
"lexicon" => 1
+
})
+
end
+44
lib/atproto/com/atproto/admin/searchAccounts.ex
···
+
defmodule Com.Atproto.Admin.SearchAccounts do
+
use Atex.Lexicon
+
+
deflexicon(%{
+
"defs" => %{
+
"main" => %{
+
"description" => "Get list of accounts that matches your search query.",
+
"output" => %{
+
"encoding" => "application/json",
+
"schema" => %{
+
"properties" => %{
+
"accounts" => %{
+
"items" => %{
+
"ref" => "com.atproto.admin.defs#accountView",
+
"type" => "ref"
+
},
+
"type" => "array"
+
},
+
"cursor" => %{"type" => "string"}
+
},
+
"required" => ["accounts"],
+
"type" => "object"
+
}
+
},
+
"parameters" => %{
+
"properties" => %{
+
"cursor" => %{"type" => "string"},
+
"email" => %{"type" => "string"},
+
"limit" => %{
+
"default" => 50,
+
"maximum" => 100,
+
"minimum" => 1,
+
"type" => "integer"
+
}
+
},
+
"type" => "params"
+
},
+
"type" => "query"
+
}
+
},
+
"id" => "com.atproto.admin.searchAccounts",
+
"lexicon" => 1
+
})
+
end
+40
lib/atproto/com/atproto/admin/sendEmail.ex
···
+
defmodule Com.Atproto.Admin.SendEmail do
+
use Atex.Lexicon
+
+
deflexicon(%{
+
"defs" => %{
+
"main" => %{
+
"description" => "Send email to a user's account email address.",
+
"input" => %{
+
"encoding" => "application/json",
+
"schema" => %{
+
"properties" => %{
+
"comment" => %{
+
"description" =>
+
"Additional comment by the sender that won't be used in the email itself but helpful to provide more context for moderators/reviewers",
+
"type" => "string"
+
},
+
"content" => %{"type" => "string"},
+
"recipientDid" => %{"format" => "did", "type" => "string"},
+
"senderDid" => %{"format" => "did", "type" => "string"},
+
"subject" => %{"type" => "string"}
+
},
+
"required" => ["recipientDid", "content", "senderDid"],
+
"type" => "object"
+
}
+
},
+
"output" => %{
+
"encoding" => "application/json",
+
"schema" => %{
+
"properties" => %{"sent" => %{"type" => "boolean"}},
+
"required" => ["sent"],
+
"type" => "object"
+
}
+
},
+
"type" => "procedure"
+
}
+
},
+
"id" => "com.atproto.admin.sendEmail",
+
"lexicon" => 1
+
})
+
end
+29
lib/atproto/com/atproto/admin/updateAccountEmail.ex
···
+
defmodule Com.Atproto.Admin.UpdateAccountEmail do
+
use Atex.Lexicon
+
+
deflexicon(%{
+
"defs" => %{
+
"main" => %{
+
"description" => "Administrative action to update an account's email.",
+
"input" => %{
+
"encoding" => "application/json",
+
"schema" => %{
+
"properties" => %{
+
"account" => %{
+
"description" => "The handle or DID of the repo.",
+
"format" => "at-identifier",
+
"type" => "string"
+
},
+
"email" => %{"type" => "string"}
+
},
+
"required" => ["account", "email"],
+
"type" => "object"
+
}
+
},
+
"type" => "procedure"
+
}
+
},
+
"id" => "com.atproto.admin.updateAccountEmail",
+
"lexicon" => 1
+
})
+
end
+25
lib/atproto/com/atproto/admin/updateAccountHandle.ex
···
+
defmodule Com.Atproto.Admin.UpdateAccountHandle do
+
use Atex.Lexicon
+
+
deflexicon(%{
+
"defs" => %{
+
"main" => %{
+
"description" => "Administrative action to update an account's handle.",
+
"input" => %{
+
"encoding" => "application/json",
+
"schema" => %{
+
"properties" => %{
+
"did" => %{"format" => "did", "type" => "string"},
+
"handle" => %{"format" => "handle", "type" => "string"}
+
},
+
"required" => ["did", "handle"],
+
"type" => "object"
+
}
+
},
+
"type" => "procedure"
+
}
+
},
+
"id" => "com.atproto.admin.updateAccountHandle",
+
"lexicon" => 1
+
})
+
end
+25
lib/atproto/com/atproto/admin/updateAccountPassword.ex
···
+
defmodule Com.Atproto.Admin.UpdateAccountPassword do
+
use Atex.Lexicon
+
+
deflexicon(%{
+
"defs" => %{
+
"main" => %{
+
"description" => "Update the password for a user account as an administrator.",
+
"input" => %{
+
"encoding" => "application/json",
+
"schema" => %{
+
"properties" => %{
+
"did" => %{"format" => "did", "type" => "string"},
+
"password" => %{"type" => "string"}
+
},
+
"required" => ["did", "password"],
+
"type" => "object"
+
}
+
},
+
"type" => "procedure"
+
}
+
},
+
"id" => "com.atproto.admin.updateAccountPassword",
+
"lexicon" => 1
+
})
+
end
+30
lib/atproto/com/atproto/admin/updateAccountSigningKey.ex
···
+
defmodule Com.Atproto.Admin.UpdateAccountSigningKey do
+
use Atex.Lexicon
+
+
deflexicon(%{
+
"defs" => %{
+
"main" => %{
+
"description" =>
+
"Administrative action to update an account's signing key in their Did document.",
+
"input" => %{
+
"encoding" => "application/json",
+
"schema" => %{
+
"properties" => %{
+
"did" => %{"format" => "did", "type" => "string"},
+
"signingKey" => %{
+
"description" => "Did-key formatted public key",
+
"format" => "did",
+
"type" => "string"
+
}
+
},
+
"required" => ["did", "signingKey"],
+
"type" => "object"
+
}
+
},
+
"type" => "procedure"
+
}
+
},
+
"id" => "com.atproto.admin.updateAccountSigningKey",
+
"lexicon" => 1
+
})
+
end
+61
lib/atproto/com/atproto/admin/updateSubjectStatus.ex
···
+
defmodule Com.Atproto.Admin.UpdateSubjectStatus do
+
use Atex.Lexicon
+
+
deflexicon(%{
+
"defs" => %{
+
"main" => %{
+
"description" =>
+
"Update the service-specific admin status of a subject (account, record, or blob).",
+
"input" => %{
+
"encoding" => "application/json",
+
"schema" => %{
+
"properties" => %{
+
"deactivated" => %{
+
"ref" => "com.atproto.admin.defs#statusAttr",
+
"type" => "ref"
+
},
+
"subject" => %{
+
"refs" => [
+
"com.atproto.admin.defs#repoRef",
+
"com.atproto.repo.strongRef",
+
"com.atproto.admin.defs#repoBlobRef"
+
],
+
"type" => "union"
+
},
+
"takedown" => %{
+
"ref" => "com.atproto.admin.defs#statusAttr",
+
"type" => "ref"
+
}
+
},
+
"required" => ["subject"],
+
"type" => "object"
+
}
+
},
+
"output" => %{
+
"encoding" => "application/json",
+
"schema" => %{
+
"properties" => %{
+
"subject" => %{
+
"refs" => [
+
"com.atproto.admin.defs#repoRef",
+
"com.atproto.repo.strongRef",
+
"com.atproto.admin.defs#repoBlobRef"
+
],
+
"type" => "union"
+
},
+
"takedown" => %{
+
"ref" => "com.atproto.admin.defs#statusAttr",
+
"type" => "ref"
+
}
+
},
+
"required" => ["subject"],
+
"type" => "object"
+
}
+
},
+
"type" => "procedure"
+
}
+
},
+
"id" => "com.atproto.admin.updateSubjectStatus",
+
"lexicon" => 1
+
})
+
end
+27
lib/atproto/com/atproto/identity/defs.ex
···
+
defmodule Com.Atproto.Identity.Defs do
+
use Atex.Lexicon
+
+
deflexicon(%{
+
"defs" => %{
+
"identityInfo" => %{
+
"properties" => %{
+
"did" => %{"format" => "did", "type" => "string"},
+
"didDoc" => %{
+
"description" => "The complete DID document for the identity.",
+
"type" => "unknown"
+
},
+
"handle" => %{
+
"description" =>
+
"The validated handle of the account; or 'handle.invalid' if the handle did not bi-directionally match the DID document.",
+
"format" => "handle",
+
"type" => "string"
+
}
+
},
+
"required" => ["did", "handle", "didDoc"],
+
"type" => "object"
+
}
+
},
+
"id" => "com.atproto.identity.defs",
+
"lexicon" => 1
+
})
+
end
+35
lib/atproto/com/atproto/identity/getRecommendedDidCredentials.ex
···
+
defmodule Com.Atproto.Identity.GetRecommendedDidCredentials do
+
use Atex.Lexicon
+
+
deflexicon(%{
+
"defs" => %{
+
"main" => %{
+
"description" =>
+
"Describe the credentials that should be included in the DID doc of an account that is migrating to this service.",
+
"output" => %{
+
"encoding" => "application/json",
+
"schema" => %{
+
"properties" => %{
+
"alsoKnownAs" => %{
+
"items" => %{"type" => "string"},
+
"type" => "array"
+
},
+
"rotationKeys" => %{
+
"description" =>
+
"Recommended rotation keys for PLC dids. Should be undefined (or ignored) for did:webs.",
+
"items" => %{"type" => "string"},
+
"type" => "array"
+
},
+
"services" => %{"type" => "unknown"},
+
"verificationMethods" => %{"type" => "unknown"}
+
},
+
"type" => "object"
+
}
+
},
+
"type" => "query"
+
}
+
},
+
"id" => "com.atproto.identity.getRecommendedDidCredentials",
+
"lexicon" => 1
+
})
+
end
+47
lib/atproto/com/atproto/identity/refreshIdentity.ex
···
+
defmodule Com.Atproto.Identity.RefreshIdentity do
+
use Atex.Lexicon
+
+
deflexicon(%{
+
"defs" => %{
+
"main" => %{
+
"description" =>
+
"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.",
+
"errors" => [
+
%{
+
"description" =>
+
"The resolution process confirmed that the handle does not resolve to any DID.",
+
"name" => "HandleNotFound"
+
},
+
%{
+
"description" => "The DID resolution process confirmed that there is no current DID.",
+
"name" => "DidNotFound"
+
},
+
%{
+
"description" => "The DID previously existed, but has been deactivated.",
+
"name" => "DidDeactivated"
+
}
+
],
+
"input" => %{
+
"encoding" => "application/json",
+
"schema" => %{
+
"properties" => %{
+
"identifier" => %{"format" => "at-identifier", "type" => "string"}
+
},
+
"required" => ["identifier"],
+
"type" => "object"
+
}
+
},
+
"output" => %{
+
"encoding" => "application/json",
+
"schema" => %{
+
"ref" => "com.atproto.identity.defs#identityInfo",
+
"type" => "ref"
+
}
+
},
+
"type" => "procedure"
+
}
+
},
+
"id" => "com.atproto.identity.refreshIdentity",
+
"lexicon" => 1
+
})
+
end
+15
lib/atproto/com/atproto/identity/requestPlcOperationSignature.ex
···
+
defmodule Com.Atproto.Identity.RequestPlcOperationSignature do
+
use Atex.Lexicon
+
+
deflexicon(%{
+
"defs" => %{
+
"main" => %{
+
"description" =>
+
"Request an email with a code to in order to request a signed PLC operation. Requires Auth.",
+
"type" => "procedure"
+
}
+
},
+
"id" => "com.atproto.identity.requestPlcOperationSignature",
+
"lexicon" => 1
+
})
+
end
+48
lib/atproto/com/atproto/identity/resolveDid.ex
···
+
defmodule Com.Atproto.Identity.ResolveDid do
+
use Atex.Lexicon
+
+
deflexicon(%{
+
"defs" => %{
+
"main" => %{
+
"description" => "Resolves DID to DID document. Does not bi-directionally verify handle.",
+
"errors" => [
+
%{
+
"description" => "The DID resolution process confirmed that there is no current DID.",
+
"name" => "DidNotFound"
+
},
+
%{
+
"description" => "The DID previously existed, but has been deactivated.",
+
"name" => "DidDeactivated"
+
}
+
],
+
"output" => %{
+
"encoding" => "application/json",
+
"schema" => %{
+
"properties" => %{
+
"didDoc" => %{
+
"description" => "The complete DID document for the identity.",
+
"type" => "unknown"
+
}
+
},
+
"required" => ["didDoc"],
+
"type" => "object"
+
}
+
},
+
"parameters" => %{
+
"properties" => %{
+
"did" => %{
+
"description" => "DID to resolve.",
+
"format" => "did",
+
"type" => "string"
+
}
+
},
+
"required" => ["did"],
+
"type" => "params"
+
},
+
"type" => "query"
+
}
+
},
+
"id" => "com.atproto.identity.resolveDid",
+
"lexicon" => 1
+
})
+
end
+41
lib/atproto/com/atproto/identity/resolveHandle.ex
···
+
defmodule Com.Atproto.Identity.ResolveHandle do
+
use Atex.Lexicon
+
+
deflexicon(%{
+
"defs" => %{
+
"main" => %{
+
"description" =>
+
"Resolves an atproto handle (hostname) to a DID. Does not necessarily bi-directionally verify against the the DID document.",
+
"errors" => [
+
%{
+
"description" =>
+
"The resolution process confirmed that the handle does not resolve to any DID.",
+
"name" => "HandleNotFound"
+
}
+
],
+
"output" => %{
+
"encoding" => "application/json",
+
"schema" => %{
+
"properties" => %{"did" => %{"format" => "did", "type" => "string"}},
+
"required" => ["did"],
+
"type" => "object"
+
}
+
},
+
"parameters" => %{
+
"properties" => %{
+
"handle" => %{
+
"description" => "The handle to resolve.",
+
"format" => "handle",
+
"type" => "string"
+
}
+
},
+
"required" => ["handle"],
+
"type" => "params"
+
},
+
"type" => "query"
+
}
+
},
+
"id" => "com.atproto.identity.resolveHandle",
+
"lexicon" => 1
+
})
+
end
+48
lib/atproto/com/atproto/identity/resolveIdentity.ex
···
+
defmodule Com.Atproto.Identity.ResolveIdentity do
+
use Atex.Lexicon
+
+
deflexicon(%{
+
"defs" => %{
+
"main" => %{
+
"description" =>
+
"Resolves an identity (DID or Handle) to a full identity (DID document and verified handle).",
+
"errors" => [
+
%{
+
"description" =>
+
"The resolution process confirmed that the handle does not resolve to any DID.",
+
"name" => "HandleNotFound"
+
},
+
%{
+
"description" => "The DID resolution process confirmed that there is no current DID.",
+
"name" => "DidNotFound"
+
},
+
%{
+
"description" => "The DID previously existed, but has been deactivated.",
+
"name" => "DidDeactivated"
+
}
+
],
+
"output" => %{
+
"encoding" => "application/json",
+
"schema" => %{
+
"ref" => "com.atproto.identity.defs#identityInfo",
+
"type" => "ref"
+
}
+
},
+
"parameters" => %{
+
"properties" => %{
+
"identifier" => %{
+
"description" => "Handle or DID to resolve.",
+
"format" => "at-identifier",
+
"type" => "string"
+
}
+
},
+
"required" => ["identifier"],
+
"type" => "params"
+
},
+
"type" => "query"
+
}
+
},
+
"id" => "com.atproto.identity.resolveIdentity",
+
"lexicon" => 1
+
})
+
end
+51
lib/atproto/com/atproto/identity/signPlcOperation.ex
···
+
defmodule Com.Atproto.Identity.SignPlcOperation do
+
use Atex.Lexicon
+
+
deflexicon(%{
+
"defs" => %{
+
"main" => %{
+
"description" =>
+
"Signs a PLC operation to update some value(s) in the requesting DID's document.",
+
"input" => %{
+
"encoding" => "application/json",
+
"schema" => %{
+
"properties" => %{
+
"alsoKnownAs" => %{
+
"items" => %{"type" => "string"},
+
"type" => "array"
+
},
+
"rotationKeys" => %{
+
"items" => %{"type" => "string"},
+
"type" => "array"
+
},
+
"services" => %{"type" => "unknown"},
+
"token" => %{
+
"description" =>
+
"A token received through com.atproto.identity.requestPlcOperationSignature",
+
"type" => "string"
+
},
+
"verificationMethods" => %{"type" => "unknown"}
+
},
+
"type" => "object"
+
}
+
},
+
"output" => %{
+
"encoding" => "application/json",
+
"schema" => %{
+
"properties" => %{
+
"operation" => %{
+
"description" => "A signed DID PLC operation.",
+
"type" => "unknown"
+
}
+
},
+
"required" => ["operation"],
+
"type" => "object"
+
}
+
},
+
"type" => "procedure"
+
}
+
},
+
"id" => "com.atproto.identity.signPlcOperation",
+
"lexicon" => 1
+
})
+
end
+23
lib/atproto/com/atproto/identity/submitPlcOperation.ex
···
+
defmodule Com.Atproto.Identity.SubmitPlcOperation do
+
use Atex.Lexicon
+
+
deflexicon(%{
+
"defs" => %{
+
"main" => %{
+
"description" =>
+
"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",
+
"input" => %{
+
"encoding" => "application/json",
+
"schema" => %{
+
"properties" => %{"operation" => %{"type" => "unknown"}},
+
"required" => ["operation"],
+
"type" => "object"
+
}
+
},
+
"type" => "procedure"
+
}
+
},
+
"id" => "com.atproto.identity.submitPlcOperation",
+
"lexicon" => 1
+
})
+
end
+29
lib/atproto/com/atproto/identity/updateHandle.ex
···
+
defmodule Com.Atproto.Identity.UpdateHandle do
+
use Atex.Lexicon
+
+
deflexicon(%{
+
"defs" => %{
+
"main" => %{
+
"description" =>
+
"Updates the current account's handle. Verifies handle validity, and updates did:plc document if necessary. Implemented by PDS, and requires auth.",
+
"input" => %{
+
"encoding" => "application/json",
+
"schema" => %{
+
"properties" => %{
+
"handle" => %{
+
"description" => "The new handle.",
+
"format" => "handle",
+
"type" => "string"
+
}
+
},
+
"required" => ["handle"],
+
"type" => "object"
+
}
+
},
+
"type" => "procedure"
+
}
+
},
+
"id" => "com.atproto.identity.updateHandle",
+
"lexicon" => 1
+
})
+
end
+170
lib/atproto/com/atproto/label/defs.ex
···
+
defmodule Com.Atproto.Label.Defs do
+
use Atex.Lexicon
+
+
deflexicon(%{
+
"defs" => %{
+
"label" => %{
+
"description" => "Metadata tag on an atproto resource (eg, repo or record).",
+
"properties" => %{
+
"cid" => %{
+
"description" =>
+
"Optionally, CID specifying the specific version of 'uri' resource this label applies to.",
+
"format" => "cid",
+
"type" => "string"
+
},
+
"cts" => %{
+
"description" => "Timestamp when this label was created.",
+
"format" => "datetime",
+
"type" => "string"
+
},
+
"exp" => %{
+
"description" => "Timestamp at which this label expires (no longer applies).",
+
"format" => "datetime",
+
"type" => "string"
+
},
+
"neg" => %{
+
"description" => "If true, this is a negation label, overwriting a previous label.",
+
"type" => "boolean"
+
},
+
"sig" => %{
+
"description" => "Signature of dag-cbor encoded label.",
+
"type" => "bytes"
+
},
+
"src" => %{
+
"description" => "DID of the actor who created this label.",
+
"format" => "did",
+
"type" => "string"
+
},
+
"uri" => %{
+
"description" =>
+
"AT URI of the record, repository (account), or other resource that this label applies to.",
+
"format" => "uri",
+
"type" => "string"
+
},
+
"val" => %{
+
"description" => "The short string name of the value or type of this label.",
+
"maxLength" => 128,
+
"type" => "string"
+
},
+
"ver" => %{
+
"description" => "The AT Protocol version of the label object.",
+
"type" => "integer"
+
}
+
},
+
"required" => ["src", "uri", "val", "cts"],
+
"type" => "object"
+
},
+
"labelValue" => %{
+
"knownValues" => [
+
"!hide",
+
"!no-promote",
+
"!warn",
+
"!no-unauthenticated",
+
"dmca-violation",
+
"doxxing",
+
"porn",
+
"sexual",
+
"nudity",
+
"nsfl",
+
"gore"
+
],
+
"type" => "string"
+
},
+
"labelValueDefinition" => %{
+
"description" => "Declares a label value and its expected interpretations and behaviors.",
+
"properties" => %{
+
"adultOnly" => %{
+
"description" =>
+
"Does the user need to have adult content enabled in order to configure this label?",
+
"type" => "boolean"
+
},
+
"blurs" => %{
+
"description" =>
+
"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.",
+
"knownValues" => ["content", "media", "none"],
+
"type" => "string"
+
},
+
"defaultSetting" => %{
+
"default" => "warn",
+
"description" => "The default setting for this label.",
+
"knownValues" => ["ignore", "warn", "hide"],
+
"type" => "string"
+
},
+
"identifier" => %{
+
"description" =>
+
"The value of the label being defined. Must only include lowercase ascii and the '-' character ([a-z-]+).",
+
"maxGraphemes" => 100,
+
"maxLength" => 100,
+
"type" => "string"
+
},
+
"locales" => %{
+
"items" => %{"ref" => "#labelValueDefinitionStrings", "type" => "ref"},
+
"type" => "array"
+
},
+
"severity" => %{
+
"description" =>
+
"How should a client visually convey this label? 'inform' means neutral and informational; 'alert' means negative and warning; 'none' means show nothing.",
+
"knownValues" => ["inform", "alert", "none"],
+
"type" => "string"
+
}
+
},
+
"required" => ["identifier", "severity", "blurs", "locales"],
+
"type" => "object"
+
},
+
"labelValueDefinitionStrings" => %{
+
"description" =>
+
"Strings which describe the label in the UI, localized into a specific language.",
+
"properties" => %{
+
"description" => %{
+
"description" =>
+
"A longer description of what the label means and why it might be applied.",
+
"maxGraphemes" => 10000,
+
"maxLength" => 100_000,
+
"type" => "string"
+
},
+
"lang" => %{
+
"description" => "The code of the language these strings are written in.",
+
"format" => "language",
+
"type" => "string"
+
},
+
"name" => %{
+
"description" => "A short human-readable name for the label.",
+
"maxGraphemes" => 64,
+
"maxLength" => 640,
+
"type" => "string"
+
}
+
},
+
"required" => ["lang", "name", "description"],
+
"type" => "object"
+
},
+
"selfLabel" => %{
+
"description" =>
+
"Metadata tag on an atproto record, published by the author within the record. Note that schemas should use #selfLabels, not #selfLabel.",
+
"properties" => %{
+
"val" => %{
+
"description" => "The short string name of the value or type of this label.",
+
"maxLength" => 128,
+
"type" => "string"
+
}
+
},
+
"required" => ["val"],
+
"type" => "object"
+
},
+
"selfLabels" => %{
+
"description" =>
+
"Metadata tags on an atproto record, published by the author within the record.",
+
"properties" => %{
+
"values" => %{
+
"items" => %{"ref" => "#selfLabel", "type" => "ref"},
+
"maxLength" => 10,
+
"type" => "array"
+
}
+
},
+
"required" => ["values"],
+
"type" => "object"
+
}
+
},
+
"id" => "com.atproto.label.defs",
+
"lexicon" => 1
+
})
+
end
+56
lib/atproto/com/atproto/label/queryLabels.ex
···
+
defmodule Com.Atproto.Label.QueryLabels do
+
use Atex.Lexicon
+
+
deflexicon(%{
+
"defs" => %{
+
"main" => %{
+
"description" =>
+
"Find labels relevant to the provided AT-URI patterns. Public endpoint for moderation services, though may return different or additional results with auth.",
+
"output" => %{
+
"encoding" => "application/json",
+
"schema" => %{
+
"properties" => %{
+
"cursor" => %{"type" => "string"},
+
"labels" => %{
+
"items" => %{
+
"ref" => "com.atproto.label.defs#label",
+
"type" => "ref"
+
},
+
"type" => "array"
+
}
+
},
+
"required" => ["labels"],
+
"type" => "object"
+
}
+
},
+
"parameters" => %{
+
"properties" => %{
+
"cursor" => %{"type" => "string"},
+
"limit" => %{
+
"default" => 50,
+
"maximum" => 250,
+
"minimum" => 1,
+
"type" => "integer"
+
},
+
"sources" => %{
+
"description" => "Optional list of label sources (DIDs) to filter on.",
+
"items" => %{"format" => "did", "type" => "string"},
+
"type" => "array"
+
},
+
"uriPatterns" => %{
+
"description" =>
+
"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.",
+
"items" => %{"type" => "string"},
+
"type" => "array"
+
}
+
},
+
"required" => ["uriPatterns"],
+
"type" => "params"
+
},
+
"type" => "query"
+
}
+
},
+
"id" => "com.atproto.label.queryLabels",
+
"lexicon" => 1
+
})
+
end
+47
lib/atproto/com/atproto/label/subscribeLabels.ex
···
+
defmodule Com.Atproto.Label.SubscribeLabels do
+
use Atex.Lexicon
+
+
deflexicon(%{
+
"defs" => %{
+
"info" => %{
+
"properties" => %{
+
"message" => %{"type" => "string"},
+
"name" => %{"knownValues" => ["OutdatedCursor"], "type" => "string"}
+
},
+
"required" => ["name"],
+
"type" => "object"
+
},
+
"labels" => %{
+
"properties" => %{
+
"labels" => %{
+
"items" => %{"ref" => "com.atproto.label.defs#label", "type" => "ref"},
+
"type" => "array"
+
},
+
"seq" => %{"type" => "integer"}
+
},
+
"required" => ["seq", "labels"],
+
"type" => "object"
+
},
+
"main" => %{
+
"description" =>
+
"Subscribe to stream of labels (and negations). Public endpoint implemented by mod services. Uses same sequencing scheme as repo event stream.",
+
"errors" => [%{"name" => "FutureCursor"}],
+
"message" => %{
+
"schema" => %{"refs" => ["#labels", "#info"], "type" => "union"}
+
},
+
"parameters" => %{
+
"properties" => %{
+
"cursor" => %{
+
"description" => "The last known event seq number to backfill from.",
+
"type" => "integer"
+
}
+
},
+
"type" => "params"
+
},
+
"type" => "subscription"
+
}
+
},
+
"id" => "com.atproto.label.subscribeLabels",
+
"lexicon" => 1
+
})
+
end
+27
lib/atproto/com/atproto/lexicon/schema.ex
···
+
defmodule Com.Atproto.Lexicon.Schema do
+
use Atex.Lexicon
+
+
deflexicon(%{
+
"defs" => %{
+
"main" => %{
+
"description" =>
+
"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).",
+
"key" => "nsid",
+
"record" => %{
+
"properties" => %{
+
"lexicon" => %{
+
"description" =>
+
"Indicates the 'version' of the Lexicon language. Must be '1' for the current atproto/Lexicon schema system.",
+
"type" => "integer"
+
}
+
},
+
"required" => ["lexicon"],
+
"type" => "object"
+
},
+
"type" => "record"
+
}
+
},
+
"id" => "com.atproto.lexicon.schema",
+
"lexicon" => 1
+
})
+
end
+81
lib/atproto/com/atproto/moderation/createReport.ex
···
+
defmodule Com.Atproto.Moderation.CreateReport do
+
use Atex.Lexicon
+
+
deflexicon(%{
+
"defs" => %{
+
"main" => %{
+
"description" =>
+
"Submit a moderation report regarding an atproto account or record. Implemented by moderation services (with PDS proxying), and requires auth.",
+
"input" => %{
+
"encoding" => "application/json",
+
"schema" => %{
+
"properties" => %{
+
"modTool" => %{"ref" => "#modTool", "type" => "ref"},
+
"reason" => %{
+
"description" => "Additional context about the content and violation.",
+
"maxGraphemes" => 2000,
+
"maxLength" => 20000,
+
"type" => "string"
+
},
+
"reasonType" => %{
+
"description" => "Indicates the broad category of violation the report is for.",
+
"ref" => "com.atproto.moderation.defs#reasonType",
+
"type" => "ref"
+
},
+
"subject" => %{
+
"refs" => ["com.atproto.admin.defs#repoRef", "com.atproto.repo.strongRef"],
+
"type" => "union"
+
}
+
},
+
"required" => ["reasonType", "subject"],
+
"type" => "object"
+
}
+
},
+
"output" => %{
+
"encoding" => "application/json",
+
"schema" => %{
+
"properties" => %{
+
"createdAt" => %{"format" => "datetime", "type" => "string"},
+
"id" => %{"type" => "integer"},
+
"reason" => %{
+
"maxGraphemes" => 2000,
+
"maxLength" => 20000,
+
"type" => "string"
+
},
+
"reasonType" => %{
+
"ref" => "com.atproto.moderation.defs#reasonType",
+
"type" => "ref"
+
},
+
"reportedBy" => %{"format" => "did", "type" => "string"},
+
"subject" => %{
+
"refs" => ["com.atproto.admin.defs#repoRef", "com.atproto.repo.strongRef"],
+
"type" => "union"
+
}
+
},
+
"required" => ["id", "reasonType", "subject", "reportedBy", "createdAt"],
+
"type" => "object"
+
}
+
},
+
"type" => "procedure"
+
},
+
"modTool" => %{
+
"description" => "Moderation tool information for tracing the source of the action",
+
"properties" => %{
+
"meta" => %{
+
"description" => "Additional arbitrary metadata about the source",
+
"type" => "unknown"
+
},
+
"name" => %{
+
"description" =>
+
"Name/identifier of the source (e.g., 'bsky-app/android', 'bsky-web/chrome')",
+
"type" => "string"
+
}
+
},
+
"required" => ["name"],
+
"type" => "object"
+
}
+
},
+
"id" => "com.atproto.moderation.createReport",
+
"lexicon" => 1
+
})
+
end
+101
lib/atproto/com/atproto/moderation/defs.ex
···
+
defmodule Com.Atproto.Moderation.Defs do
+
use Atex.Lexicon
+
+
deflexicon(%{
+
"defs" => %{
+
"reasonAppeal" => %{
+
"description" => "Appeal a previously taken moderation action",
+
"type" => "token"
+
},
+
"reasonMisleading" => %{
+
"description" =>
+
"Misleading identity, affiliation, or content. Prefer new lexicon definition `tools.ozone.report.defs#reasonMisleadingOther`.",
+
"type" => "token"
+
},
+
"reasonOther" => %{
+
"description" =>
+
"Reports not falling under another report category. Prefer new lexicon definition `tools.ozone.report.defs#reasonOther`.",
+
"type" => "token"
+
},
+
"reasonRude" => %{
+
"description" =>
+
"Rude, harassing, explicit, or otherwise unwelcoming behavior. Prefer new lexicon definition `tools.ozone.report.defs#reasonHarassmentOther`.",
+
"type" => "token"
+
},
+
"reasonSexual" => %{
+
"description" =>
+
"Unwanted or mislabeled sexual content. Prefer new lexicon definition `tools.ozone.report.defs#reasonSexualUnlabeled`.",
+
"type" => "token"
+
},
+
"reasonSpam" => %{
+
"description" =>
+
"Spam: frequent unwanted promotion, replies, mentions. Prefer new lexicon definition `tools.ozone.report.defs#reasonMisleadingSpam`.",
+
"type" => "token"
+
},
+
"reasonType" => %{
+
"knownValues" => [
+
"com.atproto.moderation.defs#reasonSpam",
+
"com.atproto.moderation.defs#reasonViolation",
+
"com.atproto.moderation.defs#reasonMisleading",
+
"com.atproto.moderation.defs#reasonSexual",
+
"com.atproto.moderation.defs#reasonRude",
+
"com.atproto.moderation.defs#reasonOther",
+
"com.atproto.moderation.defs#reasonAppeal",
+
"tools.ozone.report.defs#reasonAppeal",
+
"tools.ozone.report.defs#reasonOther",
+
"tools.ozone.report.defs#reasonViolenceAnimal",
+
"tools.ozone.report.defs#reasonViolenceThreats",
+
"tools.ozone.report.defs#reasonViolenceGraphicContent",
+
"tools.ozone.report.defs#reasonViolenceGlorification",
+
"tools.ozone.report.defs#reasonViolenceExtremistContent",
+
"tools.ozone.report.defs#reasonViolenceTrafficking",
+
"tools.ozone.report.defs#reasonViolenceOther",
+
"tools.ozone.report.defs#reasonSexualAbuseContent",
+
"tools.ozone.report.defs#reasonSexualNCII",
+
"tools.ozone.report.defs#reasonSexualDeepfake",
+
"tools.ozone.report.defs#reasonSexualAnimal",
+
"tools.ozone.report.defs#reasonSexualUnlabeled",
+
"tools.ozone.report.defs#reasonSexualOther",
+
"tools.ozone.report.defs#reasonChildSafetyCSAM",
+
"tools.ozone.report.defs#reasonChildSafetyGroom",
+
"tools.ozone.report.defs#reasonChildSafetyPrivacy",
+
"tools.ozone.report.defs#reasonChildSafetyHarassment",
+
"tools.ozone.report.defs#reasonChildSafetyOther",
+
"tools.ozone.report.defs#reasonHarassmentTroll",
+
"tools.ozone.report.defs#reasonHarassmentTargeted",
+
"tools.ozone.report.defs#reasonHarassmentHateSpeech",
+
"tools.ozone.report.defs#reasonHarassmentDoxxing",
+
"tools.ozone.report.defs#reasonHarassmentOther",
+
"tools.ozone.report.defs#reasonMisleadingBot",
+
"tools.ozone.report.defs#reasonMisleadingImpersonation",
+
"tools.ozone.report.defs#reasonMisleadingSpam",
+
"tools.ozone.report.defs#reasonMisleadingScam",
+
"tools.ozone.report.defs#reasonMisleadingElections",
+
"tools.ozone.report.defs#reasonMisleadingOther",
+
"tools.ozone.report.defs#reasonRuleSiteSecurity",
+
"tools.ozone.report.defs#reasonRuleProhibitedSales",
+
"tools.ozone.report.defs#reasonRuleBanEvasion",
+
"tools.ozone.report.defs#reasonRuleOther",
+
"tools.ozone.report.defs#reasonSelfHarmContent",
+
"tools.ozone.report.defs#reasonSelfHarmED",
+
"tools.ozone.report.defs#reasonSelfHarmStunts",
+
"tools.ozone.report.defs#reasonSelfHarmSubstances",
+
"tools.ozone.report.defs#reasonSelfHarmOther"
+
],
+
"type" => "string"
+
},
+
"reasonViolation" => %{
+
"description" =>
+
"Direct violation of server rules, laws, terms of service. Prefer new lexicon definition `tools.ozone.report.defs#reasonRuleOther`.",
+
"type" => "token"
+
},
+
"subjectType" => %{
+
"description" => "Tag describing a type of subject that might be reported.",
+
"knownValues" => ["account", "record", "chat"],
+
"type" => "string"
+
}
+
},
+
"id" => "com.atproto.moderation.defs",
+
"lexicon" => 1
+
})
+
end
+140
lib/atproto/com/atproto/repo/applyWrites.ex
···
+
defmodule Com.Atproto.Repo.ApplyWrites do
+
use Atex.Lexicon
+
+
deflexicon(%{
+
"defs" => %{
+
"create" => %{
+
"description" => "Operation which creates a new record.",
+
"properties" => %{
+
"collection" => %{"format" => "nsid", "type" => "string"},
+
"rkey" => %{
+
"description" =>
+
"NOTE: maxLength is redundant with record-key format. Keeping it temporarily to ensure backwards compatibility.",
+
"format" => "record-key",
+
"maxLength" => 512,
+
"type" => "string"
+
},
+
"value" => %{"type" => "unknown"}
+
},
+
"required" => ["collection", "value"],
+
"type" => "object"
+
},
+
"createResult" => %{
+
"properties" => %{
+
"cid" => %{"format" => "cid", "type" => "string"},
+
"uri" => %{"format" => "at-uri", "type" => "string"},
+
"validationStatus" => %{
+
"knownValues" => ["valid", "unknown"],
+
"type" => "string"
+
}
+
},
+
"required" => ["uri", "cid"],
+
"type" => "object"
+
},
+
"delete" => %{
+
"description" => "Operation which deletes an existing record.",
+
"properties" => %{
+
"collection" => %{"format" => "nsid", "type" => "string"},
+
"rkey" => %{"format" => "record-key", "type" => "string"}
+
},
+
"required" => ["collection", "rkey"],
+
"type" => "object"
+
},
+
"deleteResult" => %{
+
"properties" => %{},
+
"required" => [],
+
"type" => "object"
+
},
+
"main" => %{
+
"description" =>
+
"Apply a batch transaction of repository creates, updates, and deletes. Requires auth, implemented by PDS.",
+
"errors" => [
+
%{
+
"description" =>
+
"Indicates that the 'swapCommit' parameter did not match current commit.",
+
"name" => "InvalidSwap"
+
}
+
],
+
"input" => %{
+
"encoding" => "application/json",
+
"schema" => %{
+
"properties" => %{
+
"repo" => %{
+
"description" => "The handle or DID of the repo (aka, current account).",
+
"format" => "at-identifier",
+
"type" => "string"
+
},
+
"swapCommit" => %{
+
"description" =>
+
"If provided, the entire operation will fail if the current repo commit CID does not match this value. Used to prevent conflicting repo mutations.",
+
"format" => "cid",
+
"type" => "string"
+
},
+
"validate" => %{
+
"description" =>
+
"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.",
+
"type" => "boolean"
+
},
+
"writes" => %{
+
"items" => %{
+
"closed" => true,
+
"refs" => ["#create", "#update", "#delete"],
+
"type" => "union"
+
},
+
"type" => "array"
+
}
+
},
+
"required" => ["repo", "writes"],
+
"type" => "object"
+
}
+
},
+
"output" => %{
+
"encoding" => "application/json",
+
"schema" => %{
+
"properties" => %{
+
"commit" => %{
+
"ref" => "com.atproto.repo.defs#commitMeta",
+
"type" => "ref"
+
},
+
"results" => %{
+
"items" => %{
+
"closed" => true,
+
"refs" => ["#createResult", "#updateResult", "#deleteResult"],
+
"type" => "union"
+
},
+
"type" => "array"
+
}
+
},
+
"required" => [],
+
"type" => "object"
+
}
+
},
+
"type" => "procedure"
+
},
+
"update" => %{
+
"description" => "Operation which updates an existing record.",
+
"properties" => %{
+
"collection" => %{"format" => "nsid", "type" => "string"},
+
"rkey" => %{"format" => "record-key", "type" => "string"},
+
"value" => %{"type" => "unknown"}
+
},
+
"required" => ["collection", "rkey", "value"],
+
"type" => "object"
+
},
+
"updateResult" => %{
+
"properties" => %{
+
"cid" => %{"format" => "cid", "type" => "string"},
+
"uri" => %{"format" => "at-uri", "type" => "string"},
+
"validationStatus" => %{
+
"knownValues" => ["valid", "unknown"],
+
"type" => "string"
+
}
+
},
+
"required" => ["uri", "cid"],
+
"type" => "object"
+
}
+
},
+
"id" => "com.atproto.repo.applyWrites",
+
"lexicon" => 1
+
})
+
end
+79
lib/atproto/com/atproto/repo/createRecord.ex
···
+
defmodule Com.Atproto.Repo.CreateRecord do
+
use Atex.Lexicon
+
+
deflexicon(%{
+
"defs" => %{
+
"main" => %{
+
"description" =>
+
"Create a single new repository record. Requires auth, implemented by PDS.",
+
"errors" => [
+
%{
+
"description" => "Indicates that 'swapCommit' didn't match current repo commit.",
+
"name" => "InvalidSwap"
+
}
+
],
+
"input" => %{
+
"encoding" => "application/json",
+
"schema" => %{
+
"properties" => %{
+
"collection" => %{
+
"description" => "The NSID of the record collection.",
+
"format" => "nsid",
+
"type" => "string"
+
},
+
"record" => %{
+
"description" => "The record itself. Must contain a $type field.",
+
"type" => "unknown"
+
},
+
"repo" => %{
+
"description" => "The handle or DID of the repo (aka, current account).",
+
"format" => "at-identifier",
+
"type" => "string"
+
},
+
"rkey" => %{
+
"description" => "The Record Key.",
+
"format" => "record-key",
+
"maxLength" => 512,
+
"type" => "string"
+
},
+
"swapCommit" => %{
+
"description" => "Compare and swap with the previous commit by CID.",
+
"format" => "cid",
+
"type" => "string"
+
},
+
"validate" => %{
+
"description" =>
+
"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.",
+
"type" => "boolean"
+
}
+
},
+
"required" => ["repo", "collection", "record"],
+
"type" => "object"
+
}
+
},
+
"output" => %{
+
"encoding" => "application/json",
+
"schema" => %{
+
"properties" => %{
+
"cid" => %{"format" => "cid", "type" => "string"},
+
"commit" => %{
+
"ref" => "com.atproto.repo.defs#commitMeta",
+
"type" => "ref"
+
},
+
"uri" => %{"format" => "at-uri", "type" => "string"},
+
"validationStatus" => %{
+
"knownValues" => ["valid", "unknown"],
+
"type" => "string"
+
}
+
},
+
"required" => ["uri", "cid"],
+
"type" => "object"
+
}
+
},
+
"type" => "procedure"
+
}
+
},
+
"id" => "com.atproto.repo.createRecord",
+
"lexicon" => 1
+
})
+
end
+18
lib/atproto/com/atproto/repo/defs.ex
···
+
defmodule Com.Atproto.Repo.Defs do
+
use Atex.Lexicon
+
+
deflexicon(%{
+
"defs" => %{
+
"commitMeta" => %{
+
"properties" => %{
+
"cid" => %{"format" => "cid", "type" => "string"},
+
"rev" => %{"format" => "tid", "type" => "string"}
+
},
+
"required" => ["cid", "rev"],
+
"type" => "object"
+
}
+
},
+
"id" => "com.atproto.repo.defs",
+
"lexicon" => 1
+
})
+
end
+62
lib/atproto/com/atproto/repo/deleteRecord.ex
···
+
defmodule Com.Atproto.Repo.DeleteRecord do
+
use Atex.Lexicon
+
+
deflexicon(%{
+
"defs" => %{
+
"main" => %{
+
"description" =>
+
"Delete a repository record, or ensure it doesn't exist. Requires auth, implemented by PDS.",
+
"errors" => [%{"name" => "InvalidSwap"}],
+
"input" => %{
+
"encoding" => "application/json",
+
"schema" => %{
+
"properties" => %{
+
"collection" => %{
+
"description" => "The NSID of the record collection.",
+
"format" => "nsid",
+
"type" => "string"
+
},
+
"repo" => %{
+
"description" => "The handle or DID of the repo (aka, current account).",
+
"format" => "at-identifier",
+
"type" => "string"
+
},
+
"rkey" => %{
+
"description" => "The Record Key.",
+
"format" => "record-key",
+
"type" => "string"
+
},
+
"swapCommit" => %{
+
"description" => "Compare and swap with the previous commit by CID.",
+
"format" => "cid",
+
"type" => "string"
+
},
+
"swapRecord" => %{
+
"description" => "Compare and swap with the previous record by CID.",
+
"format" => "cid",
+
"type" => "string"
+
}
+
},
+
"required" => ["repo", "collection", "rkey"],
+
"type" => "object"
+
}
+
},
+
"output" => %{
+
"encoding" => "application/json",
+
"schema" => %{
+
"properties" => %{
+
"commit" => %{
+
"ref" => "com.atproto.repo.defs#commitMeta",
+
"type" => "ref"
+
}
+
},
+
"type" => "object"
+
}
+
},
+
"type" => "procedure"
+
}
+
},
+
"id" => "com.atproto.repo.deleteRecord",
+
"lexicon" => 1
+
})
+
end
+52
lib/atproto/com/atproto/repo/describeRepo.ex
···
+
defmodule Com.Atproto.Repo.DescribeRepo do
+
use Atex.Lexicon
+
+
deflexicon(%{
+
"defs" => %{
+
"main" => %{
+
"description" =>
+
"Get information about an account and repository, including the list of collections. Does not require auth.",
+
"output" => %{
+
"encoding" => "application/json",
+
"schema" => %{
+
"properties" => %{
+
"collections" => %{
+
"description" =>
+
"List of all the collections (NSIDs) for which this repo contains at least one record.",
+
"items" => %{"format" => "nsid", "type" => "string"},
+
"type" => "array"
+
},
+
"did" => %{"format" => "did", "type" => "string"},
+
"didDoc" => %{
+
"description" => "The complete DID document for this account.",
+
"type" => "unknown"
+
},
+
"handle" => %{"format" => "handle", "type" => "string"},
+
"handleIsCorrect" => %{
+
"description" =>
+
"Indicates if handle is currently valid (resolves bi-directionally)",
+
"type" => "boolean"
+
}
+
},
+
"required" => ["handle", "did", "didDoc", "collections", "handleIsCorrect"],
+
"type" => "object"
+
}
+
},
+
"parameters" => %{
+
"properties" => %{
+
"repo" => %{
+
"description" => "The handle or DID of the repo.",
+
"format" => "at-identifier",
+
"type" => "string"
+
}
+
},
+
"required" => ["repo"],
+
"type" => "params"
+
},
+
"type" => "query"
+
}
+
},
+
"id" => "com.atproto.repo.describeRepo",
+
"lexicon" => 1
+
})
+
end
+54
lib/atproto/com/atproto/repo/getRecord.ex
···
+
defmodule Com.Atproto.Repo.GetRecord do
+
use Atex.Lexicon
+
+
deflexicon(%{
+
"defs" => %{
+
"main" => %{
+
"description" => "Get a single record from a repository. Does not require auth.",
+
"errors" => [%{"name" => "RecordNotFound"}],
+
"output" => %{
+
"encoding" => "application/json",
+
"schema" => %{
+
"properties" => %{
+
"cid" => %{"format" => "cid", "type" => "string"},
+
"uri" => %{"format" => "at-uri", "type" => "string"},
+
"value" => %{"type" => "unknown"}
+
},
+
"required" => ["uri", "value"],
+
"type" => "object"
+
}
+
},
+
"parameters" => %{
+
"properties" => %{
+
"cid" => %{
+
"description" =>
+
"The CID of the version of the record. If not specified, then return the most recent version.",
+
"format" => "cid",
+
"type" => "string"
+
},
+
"collection" => %{
+
"description" => "The NSID of the record collection.",
+
"format" => "nsid",
+
"type" => "string"
+
},
+
"repo" => %{
+
"description" => "The handle or DID of the repo.",
+
"format" => "at-identifier",
+
"type" => "string"
+
},
+
"rkey" => %{
+
"description" => "The Record Key.",
+
"format" => "record-key",
+
"type" => "string"
+
}
+
},
+
"required" => ["repo", "collection", "rkey"],
+
"type" => "params"
+
},
+
"type" => "query"
+
}
+
},
+
"id" => "com.atproto.repo.getRecord",
+
"lexicon" => 1
+
})
+
end
+16
lib/atproto/com/atproto/repo/importRepo.ex
···
+
defmodule Com.Atproto.Repo.ImportRepo do
+
use Atex.Lexicon
+
+
deflexicon(%{
+
"defs" => %{
+
"main" => %{
+
"description" =>
+
"Import a repo in the form of a CAR file. Requires Content-Length HTTP header to be set.",
+
"input" => %{"encoding" => "application/vnd.ipld.car"},
+
"type" => "procedure"
+
}
+
},
+
"id" => "com.atproto.repo.importRepo",
+
"lexicon" => 1
+
})
+
end
+49
lib/atproto/com/atproto/repo/listMissingBlobs.ex
···
+
defmodule Com.Atproto.Repo.ListMissingBlobs do
+
use Atex.Lexicon
+
+
deflexicon(%{
+
"defs" => %{
+
"main" => %{
+
"description" =>
+
"Returns a list of missing blobs for the requesting account. Intended to be used in the account migration flow.",
+
"output" => %{
+
"encoding" => "application/json",
+
"schema" => %{
+
"properties" => %{
+
"blobs" => %{
+
"items" => %{"ref" => "#recordBlob", "type" => "ref"},
+
"type" => "array"
+
},
+
"cursor" => %{"type" => "string"}
+
},
+
"required" => ["blobs"],
+
"type" => "object"
+
}
+
},
+
"parameters" => %{
+
"properties" => %{
+
"cursor" => %{"type" => "string"},
+
"limit" => %{
+
"default" => 500,
+
"maximum" => 1000,
+
"minimum" => 1,
+
"type" => "integer"
+
}
+
},
+
"type" => "params"
+
},
+
"type" => "query"
+
},
+
"recordBlob" => %{
+
"properties" => %{
+
"cid" => %{"format" => "cid", "type" => "string"},
+
"recordUri" => %{"format" => "at-uri", "type" => "string"}
+
},
+
"required" => ["cid", "recordUri"],
+
"type" => "object"
+
}
+
},
+
"id" => "com.atproto.repo.listMissingBlobs",
+
"lexicon" => 1
+
})
+
end
+66
lib/atproto/com/atproto/repo/listRecords.ex
···
+
defmodule Com.Atproto.Repo.ListRecords do
+
use Atex.Lexicon
+
+
deflexicon(%{
+
"defs" => %{
+
"main" => %{
+
"description" =>
+
"List a range of records in a repository, matching a specific collection. Does not require auth.",
+
"output" => %{
+
"encoding" => "application/json",
+
"schema" => %{
+
"properties" => %{
+
"cursor" => %{"type" => "string"},
+
"records" => %{
+
"items" => %{"ref" => "#record", "type" => "ref"},
+
"type" => "array"
+
}
+
},
+
"required" => ["records"],
+
"type" => "object"
+
}
+
},
+
"parameters" => %{
+
"properties" => %{
+
"collection" => %{
+
"description" => "The NSID of the record type.",
+
"format" => "nsid",
+
"type" => "string"
+
},
+
"cursor" => %{"type" => "string"},
+
"limit" => %{
+
"default" => 50,
+
"description" => "The number of records to return.",
+
"maximum" => 100,
+
"minimum" => 1,
+
"type" => "integer"
+
},
+
"repo" => %{
+
"description" => "The handle or DID of the repo.",
+
"format" => "at-identifier",
+
"type" => "string"
+
},
+
"reverse" => %{
+
"description" => "Flag to reverse the order of the returned records.",
+
"type" => "boolean"
+
}
+
},
+
"required" => ["repo", "collection"],
+
"type" => "params"
+
},
+
"type" => "query"
+
},
+
"record" => %{
+
"properties" => %{
+
"cid" => %{"format" => "cid", "type" => "string"},
+
"uri" => %{"format" => "at-uri", "type" => "string"},
+
"value" => %{"type" => "unknown"}
+
},
+
"required" => ["uri", "cid", "value"],
+
"type" => "object"
+
}
+
},
+
"id" => "com.atproto.repo.listRecords",
+
"lexicon" => 1
+
})
+
end
+81
lib/atproto/com/atproto/repo/putRecord.ex
···
+
defmodule Com.Atproto.Repo.PutRecord do
+
use Atex.Lexicon
+
+
deflexicon(%{
+
"defs" => %{
+
"main" => %{
+
"description" =>
+
"Write a repository record, creating or updating it as needed. Requires auth, implemented by PDS.",
+
"errors" => [%{"name" => "InvalidSwap"}],
+
"input" => %{
+
"encoding" => "application/json",
+
"schema" => %{
+
"nullable" => ["swapRecord"],
+
"properties" => %{
+
"collection" => %{
+
"description" => "The NSID of the record collection.",
+
"format" => "nsid",
+
"type" => "string"
+
},
+
"record" => %{
+
"description" => "The record to write.",
+
"type" => "unknown"
+
},
+
"repo" => %{
+
"description" => "The handle or DID of the repo (aka, current account).",
+
"format" => "at-identifier",
+
"type" => "string"
+
},
+
"rkey" => %{
+
"description" => "The Record Key.",
+
"format" => "record-key",
+
"maxLength" => 512,
+
"type" => "string"
+
},
+
"swapCommit" => %{
+
"description" => "Compare and swap with the previous commit by CID.",
+
"format" => "cid",
+
"type" => "string"
+
},
+
"swapRecord" => %{
+
"description" =>
+
"Compare and swap with the previous record by CID. WARNING: nullable and optional field; may cause problems with golang implementation",
+
"format" => "cid",
+
"type" => "string"
+
},
+
"validate" => %{
+
"description" =>
+
"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.",
+
"type" => "boolean"
+
}
+
},
+
"required" => ["repo", "collection", "rkey", "record"],
+
"type" => "object"
+
}
+
},
+
"output" => %{
+
"encoding" => "application/json",
+
"schema" => %{
+
"properties" => %{
+
"cid" => %{"format" => "cid", "type" => "string"},
+
"commit" => %{
+
"ref" => "com.atproto.repo.defs#commitMeta",
+
"type" => "ref"
+
},
+
"uri" => %{"format" => "at-uri", "type" => "string"},
+
"validationStatus" => %{
+
"knownValues" => ["valid", "unknown"],
+
"type" => "string"
+
}
+
},
+
"required" => ["uri", "cid"],
+
"type" => "object"
+
}
+
},
+
"type" => "procedure"
+
}
+
},
+
"id" => "com.atproto.repo.putRecord",
+
"lexicon" => 1
+
})
+
end
+19
lib/atproto/com/atproto/repo/strongRef.ex
···
+
defmodule Com.Atproto.Repo.StrongRef do
+
use Atex.Lexicon
+
+
deflexicon(%{
+
"defs" => %{
+
"main" => %{
+
"properties" => %{
+
"cid" => %{"format" => "cid", "type" => "string"},
+
"uri" => %{"format" => "at-uri", "type" => "string"}
+
},
+
"required" => ["uri", "cid"],
+
"type" => "object"
+
}
+
},
+
"description" => "A URI with a content-hash fingerprint.",
+
"id" => "com.atproto.repo.strongRef",
+
"lexicon" => 1
+
})
+
end
+24
lib/atproto/com/atproto/repo/uploadBlob.ex
···
+
defmodule Com.Atproto.Repo.UploadBlob do
+
use Atex.Lexicon
+
+
deflexicon(%{
+
"defs" => %{
+
"main" => %{
+
"description" =>
+
"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.",
+
"input" => %{"encoding" => "*/*"},
+
"output" => %{
+
"encoding" => "application/json",
+
"schema" => %{
+
"properties" => %{"blob" => %{"type" => "blob"}},
+
"required" => ["blob"],
+
"type" => "object"
+
}
+
},
+
"type" => "procedure"
+
}
+
},
+
"id" => "com.atproto.repo.uploadBlob",
+
"lexicon" => 1
+
})
+
end
+15
lib/atproto/com/atproto/server/activateAccount.ex
···
+
defmodule Com.Atproto.Server.ActivateAccount do
+
use Atex.Lexicon
+
+
deflexicon(%{
+
"defs" => %{
+
"main" => %{
+
"description" =>
+
"Activates a currently deactivated account. Used to finalize account migration after the account's repo is imported and identity is setup.",
+
"type" => "procedure"
+
}
+
},
+
"id" => "com.atproto.server.activateAccount",
+
"lexicon" => 1
+
})
+
end
+43
lib/atproto/com/atproto/server/checkAccountStatus.ex
···
+
defmodule Com.Atproto.Server.CheckAccountStatus do
+
use Atex.Lexicon
+
+
deflexicon(%{
+
"defs" => %{
+
"main" => %{
+
"description" =>
+
"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.",
+
"output" => %{
+
"encoding" => "application/json",
+
"schema" => %{
+
"properties" => %{
+
"activated" => %{"type" => "boolean"},
+
"expectedBlobs" => %{"type" => "integer"},
+
"importedBlobs" => %{"type" => "integer"},
+
"indexedRecords" => %{"type" => "integer"},
+
"privateStateValues" => %{"type" => "integer"},
+
"repoBlocks" => %{"type" => "integer"},
+
"repoCommit" => %{"format" => "cid", "type" => "string"},
+
"repoRev" => %{"type" => "string"},
+
"validDid" => %{"type" => "boolean"}
+
},
+
"required" => [
+
"activated",
+
"validDid",
+
"repoCommit",
+
"repoRev",
+
"repoBlocks",
+
"indexedRecords",
+
"privateStateValues",
+
"expectedBlobs",
+
"importedBlobs"
+
],
+
"type" => "object"
+
}
+
},
+
"type" => "query"
+
}
+
},
+
"id" => "com.atproto.server.checkAccountStatus",
+
"lexicon" => 1
+
})
+
end
+32
lib/atproto/com/atproto/server/confirmEmail.ex
···
+
defmodule Com.Atproto.Server.ConfirmEmail do
+
use Atex.Lexicon
+
+
deflexicon(%{
+
"defs" => %{
+
"main" => %{
+
"description" =>
+
"Confirm an email using a token from com.atproto.server.requestEmailConfirmation.",
+
"errors" => [
+
%{"name" => "AccountNotFound"},
+
%{"name" => "ExpiredToken"},
+
%{"name" => "InvalidToken"},
+
%{"name" => "InvalidEmail"}
+
],
+
"input" => %{
+
"encoding" => "application/json",
+
"schema" => %{
+
"properties" => %{
+
"email" => %{"type" => "string"},
+
"token" => %{"type" => "string"}
+
},
+
"required" => ["email", "token"],
+
"type" => "object"
+
}
+
},
+
"type" => "procedure"
+
}
+
},
+
"id" => "com.atproto.server.confirmEmail",
+
"lexicon" => 1
+
})
+
end
+83
lib/atproto/com/atproto/server/createAccount.ex
···
+
defmodule Com.Atproto.Server.CreateAccount do
+
use Atex.Lexicon
+
+
deflexicon(%{
+
"defs" => %{
+
"main" => %{
+
"description" => "Create an account. Implemented by PDS.",
+
"errors" => [
+
%{"name" => "InvalidHandle"},
+
%{"name" => "InvalidPassword"},
+
%{"name" => "InvalidInviteCode"},
+
%{"name" => "HandleNotAvailable"},
+
%{"name" => "UnsupportedDomain"},
+
%{"name" => "UnresolvableDid"},
+
%{"name" => "IncompatibleDidDoc"}
+
],
+
"input" => %{
+
"encoding" => "application/json",
+
"schema" => %{
+
"properties" => %{
+
"did" => %{
+
"description" => "Pre-existing atproto DID, being imported to a new account.",
+
"format" => "did",
+
"type" => "string"
+
},
+
"email" => %{"type" => "string"},
+
"handle" => %{
+
"description" => "Requested handle for the account.",
+
"format" => "handle",
+
"type" => "string"
+
},
+
"inviteCode" => %{"type" => "string"},
+
"password" => %{
+
"description" =>
+
"Initial account password. May need to meet instance-specific password strength requirements.",
+
"type" => "string"
+
},
+
"plcOp" => %{
+
"description" =>
+
"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.",
+
"type" => "unknown"
+
},
+
"recoveryKey" => %{
+
"description" =>
+
"DID PLC rotation key (aka, recovery key) to be included in PLC creation operation.",
+
"type" => "string"
+
},
+
"verificationCode" => %{"type" => "string"},
+
"verificationPhone" => %{"type" => "string"}
+
},
+
"required" => ["handle"],
+
"type" => "object"
+
}
+
},
+
"output" => %{
+
"encoding" => "application/json",
+
"schema" => %{
+
"description" => "Account login session returned on successful account creation.",
+
"properties" => %{
+
"accessJwt" => %{"type" => "string"},
+
"did" => %{
+
"description" => "The DID of the new account.",
+
"format" => "did",
+
"type" => "string"
+
},
+
"didDoc" => %{
+
"description" => "Complete DID document.",
+
"type" => "unknown"
+
},
+
"handle" => %{"format" => "handle", "type" => "string"},
+
"refreshJwt" => %{"type" => "string"}
+
},
+
"required" => ["accessJwt", "refreshJwt", "handle", "did"],
+
"type" => "object"
+
}
+
},
+
"type" => "procedure"
+
}
+
},
+
"id" => "com.atproto.server.createAccount",
+
"lexicon" => 1
+
})
+
end
+47
lib/atproto/com/atproto/server/createAppPassword.ex
···
+
defmodule Com.Atproto.Server.CreateAppPassword do
+
use Atex.Lexicon
+
+
deflexicon(%{
+
"defs" => %{
+
"appPassword" => %{
+
"properties" => %{
+
"createdAt" => %{"format" => "datetime", "type" => "string"},
+
"name" => %{"type" => "string"},
+
"password" => %{"type" => "string"},
+
"privileged" => %{"type" => "boolean"}
+
},
+
"required" => ["name", "password", "createdAt"],
+
"type" => "object"
+
},
+
"main" => %{
+
"description" => "Create an App Password.",
+
"errors" => [%{"name" => "AccountTakedown"}],
+
"input" => %{
+
"encoding" => "application/json",
+
"schema" => %{
+
"properties" => %{
+
"name" => %{
+
"description" => "A short name for the App Password, to help distinguish them.",
+
"type" => "string"
+
},
+
"privileged" => %{
+
"description" =>
+
"If an app password has 'privileged' access to possibly sensitive account state. Meant for use with trusted clients.",
+
"type" => "boolean"
+
}
+
},
+
"required" => ["name"],
+
"type" => "object"
+
}
+
},
+
"output" => %{
+
"encoding" => "application/json",
+
"schema" => %{"ref" => "#appPassword", "type" => "ref"}
+
},
+
"type" => "procedure"
+
}
+
},
+
"id" => "com.atproto.server.createAppPassword",
+
"lexicon" => 1
+
})
+
end
+33
lib/atproto/com/atproto/server/createInviteCode.ex
···
+
defmodule Com.Atproto.Server.CreateInviteCode do
+
use Atex.Lexicon
+
+
deflexicon(%{
+
"defs" => %{
+
"main" => %{
+
"description" => "Create an invite code.",
+
"input" => %{
+
"encoding" => "application/json",
+
"schema" => %{
+
"properties" => %{
+
"forAccount" => %{"format" => "did", "type" => "string"},
+
"useCount" => %{"type" => "integer"}
+
},
+
"required" => ["useCount"],
+
"type" => "object"
+
}
+
},
+
"output" => %{
+
"encoding" => "application/json",
+
"schema" => %{
+
"properties" => %{"code" => %{"type" => "string"}},
+
"required" => ["code"],
+
"type" => "object"
+
}
+
},
+
"type" => "procedure"
+
}
+
},
+
"id" => "com.atproto.server.createInviteCode",
+
"lexicon" => 1
+
})
+
end
+50
lib/atproto/com/atproto/server/createInviteCodes.ex
···
+
defmodule Com.Atproto.Server.CreateInviteCodes do
+
use Atex.Lexicon
+
+
deflexicon(%{
+
"defs" => %{
+
"accountCodes" => %{
+
"properties" => %{
+
"account" => %{"type" => "string"},
+
"codes" => %{"items" => %{"type" => "string"}, "type" => "array"}
+
},
+
"required" => ["account", "codes"],
+
"type" => "object"
+
},
+
"main" => %{
+
"description" => "Create invite codes.",
+
"input" => %{
+
"encoding" => "application/json",
+
"schema" => %{
+
"properties" => %{
+
"codeCount" => %{"default" => 1, "type" => "integer"},
+
"forAccounts" => %{
+
"items" => %{"format" => "did", "type" => "string"},
+
"type" => "array"
+
},
+
"useCount" => %{"type" => "integer"}
+
},
+
"required" => ["codeCount", "useCount"],
+
"type" => "object"
+
}
+
},
+
"output" => %{
+
"encoding" => "application/json",
+
"schema" => %{
+
"properties" => %{
+
"codes" => %{
+
"items" => %{"ref" => "#accountCodes", "type" => "ref"},
+
"type" => "array"
+
}
+
},
+
"required" => ["codes"],
+
"type" => "object"
+
}
+
},
+
"type" => "procedure"
+
}
+
},
+
"id" => "com.atproto.server.createInviteCodes",
+
"lexicon" => 1
+
})
+
end
+63
lib/atproto/com/atproto/server/createSession.ex
···
+
defmodule Com.Atproto.Server.CreateSession do
+
use Atex.Lexicon
+
+
deflexicon(%{
+
"defs" => %{
+
"main" => %{
+
"description" => "Create an authentication session.",
+
"errors" => [
+
%{"name" => "AccountTakedown"},
+
%{"name" => "AuthFactorTokenRequired"}
+
],
+
"input" => %{
+
"encoding" => "application/json",
+
"schema" => %{
+
"properties" => %{
+
"allowTakendown" => %{
+
"description" =>
+
"When true, instead of throwing error for takendown accounts, a valid response with a narrow scoped token will be returned",
+
"type" => "boolean"
+
},
+
"authFactorToken" => %{"type" => "string"},
+
"identifier" => %{
+
"description" =>
+
"Handle or other identifier supported by the server for the authenticating user.",
+
"type" => "string"
+
},
+
"password" => %{"type" => "string"}
+
},
+
"required" => ["identifier", "password"],
+
"type" => "object"
+
}
+
},
+
"output" => %{
+
"encoding" => "application/json",
+
"schema" => %{
+
"properties" => %{
+
"accessJwt" => %{"type" => "string"},
+
"active" => %{"type" => "boolean"},
+
"did" => %{"format" => "did", "type" => "string"},
+
"didDoc" => %{"type" => "unknown"},
+
"email" => %{"type" => "string"},
+
"emailAuthFactor" => %{"type" => "boolean"},
+
"emailConfirmed" => %{"type" => "boolean"},
+
"handle" => %{"format" => "handle", "type" => "string"},
+
"refreshJwt" => %{"type" => "string"},
+
"status" => %{
+
"description" =>
+
"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.",
+
"knownValues" => ["takendown", "suspended", "deactivated"],
+
"type" => "string"
+
}
+
},
+
"required" => ["accessJwt", "refreshJwt", "handle", "did"],
+
"type" => "object"
+
}
+
},
+
"type" => "procedure"
+
}
+
},
+
"id" => "com.atproto.server.createSession",
+
"lexicon" => 1
+
})
+
end
+29
lib/atproto/com/atproto/server/deactivateAccount.ex
···
+
defmodule Com.Atproto.Server.DeactivateAccount do
+
use Atex.Lexicon
+
+
deflexicon(%{
+
"defs" => %{
+
"main" => %{
+
"description" =>
+
"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.",
+
"input" => %{
+
"encoding" => "application/json",
+
"schema" => %{
+
"properties" => %{
+
"deleteAfter" => %{
+
"description" =>
+
"A recommendation to server as to how long they should hold onto the deactivated account before deleting.",
+
"format" => "datetime",
+
"type" => "string"
+
}
+
},
+
"type" => "object"
+
}
+
},
+
"type" => "procedure"
+
}
+
},
+
"id" => "com.atproto.server.deactivateAccount",
+
"lexicon" => 1
+
})
+
end
+42
lib/atproto/com/atproto/server/defs.ex
···
+
defmodule Com.Atproto.Server.Defs do
+
use Atex.Lexicon
+
+
deflexicon(%{
+
"defs" => %{
+
"inviteCode" => %{
+
"properties" => %{
+
"available" => %{"type" => "integer"},
+
"code" => %{"type" => "string"},
+
"createdAt" => %{"format" => "datetime", "type" => "string"},
+
"createdBy" => %{"type" => "string"},
+
"disabled" => %{"type" => "boolean"},
+
"forAccount" => %{"type" => "string"},
+
"uses" => %{
+
"items" => %{"ref" => "#inviteCodeUse", "type" => "ref"},
+
"type" => "array"
+
}
+
},
+
"required" => [
+
"code",
+
"available",
+
"disabled",
+
"forAccount",
+
"createdBy",
+
"createdAt",
+
"uses"
+
],
+
"type" => "object"
+
},
+
"inviteCodeUse" => %{
+
"properties" => %{
+
"usedAt" => %{"format" => "datetime", "type" => "string"},
+
"usedBy" => %{"format" => "did", "type" => "string"}
+
},
+
"required" => ["usedBy", "usedAt"],
+
"type" => "object"
+
}
+
},
+
"id" => "com.atproto.server.defs",
+
"lexicon" => 1
+
})
+
end
+28
lib/atproto/com/atproto/server/deleteAccount.ex
···
+
defmodule Com.Atproto.Server.DeleteAccount do
+
use Atex.Lexicon
+
+
deflexicon(%{
+
"defs" => %{
+
"main" => %{
+
"description" =>
+
"Delete an actor's account with a token and password. Can only be called after requesting a deletion token. Requires auth.",
+
"errors" => [%{"name" => "ExpiredToken"}, %{"name" => "InvalidToken"}],
+
"input" => %{
+
"encoding" => "application/json",
+
"schema" => %{
+
"properties" => %{
+
"did" => %{"format" => "did", "type" => "string"},
+
"password" => %{"type" => "string"},
+
"token" => %{"type" => "string"}
+
},
+
"required" => ["did", "password", "token"],
+
"type" => "object"
+
}
+
},
+
"type" => "procedure"
+
}
+
},
+
"id" => "com.atproto.server.deleteAccount",
+
"lexicon" => 1
+
})
+
end
+14
lib/atproto/com/atproto/server/deleteSession.ex
···
+
defmodule Com.Atproto.Server.DeleteSession do
+
use Atex.Lexicon
+
+
deflexicon(%{
+
"defs" => %{
+
"main" => %{
+
"description" => "Delete the current session. Requires auth.",
+
"type" => "procedure"
+
}
+
},
+
"id" => "com.atproto.server.deleteSession",
+
"lexicon" => 1
+
})
+
end
+61
lib/atproto/com/atproto/server/describeServer.ex
···
+
defmodule Com.Atproto.Server.DescribeServer do
+
use Atex.Lexicon
+
+
deflexicon(%{
+
"defs" => %{
+
"contact" => %{
+
"properties" => %{"email" => %{"type" => "string"}},
+
"type" => "object"
+
},
+
"links" => %{
+
"properties" => %{
+
"privacyPolicy" => %{"format" => "uri", "type" => "string"},
+
"termsOfService" => %{"format" => "uri", "type" => "string"}
+
},
+
"type" => "object"
+
},
+
"main" => %{
+
"description" =>
+
"Describes the server's account creation requirements and capabilities. Implemented by PDS.",
+
"output" => %{
+
"encoding" => "application/json",
+
"schema" => %{
+
"properties" => %{
+
"availableUserDomains" => %{
+
"description" => "List of domain suffixes that can be used in account handles.",
+
"items" => %{"type" => "string"},
+
"type" => "array"
+
},
+
"contact" => %{
+
"description" => "Contact information",
+
"ref" => "#contact",
+
"type" => "ref"
+
},
+
"did" => %{"format" => "did", "type" => "string"},
+
"inviteCodeRequired" => %{
+
"description" =>
+
"If true, an invite code must be supplied to create an account on this instance.",
+
"type" => "boolean"
+
},
+
"links" => %{
+
"description" => "URLs of service policy documents.",
+
"ref" => "#links",
+
"type" => "ref"
+
},
+
"phoneVerificationRequired" => %{
+
"description" =>
+
"If true, a phone verification token must be supplied to create an account on this instance.",
+
"type" => "boolean"
+
}
+
},
+
"required" => ["did", "availableUserDomains"],
+
"type" => "object"
+
}
+
},
+
"type" => "query"
+
}
+
},
+
"id" => "com.atproto.server.describeServer",
+
"lexicon" => 1
+
})
+
end
+43
lib/atproto/com/atproto/server/getAccountInviteCodes.ex
···
+
defmodule Com.Atproto.Server.GetAccountInviteCodes do
+
use Atex.Lexicon
+
+
deflexicon(%{
+
"defs" => %{
+
"main" => %{
+
"description" => "Get all invite codes for the current account. Requires auth.",
+
"errors" => [%{"name" => "DuplicateCreate"}],
+
"output" => %{
+
"encoding" => "application/json",
+
"schema" => %{
+
"properties" => %{
+
"codes" => %{
+
"items" => %{
+
"ref" => "com.atproto.server.defs#inviteCode",
+
"type" => "ref"
+
},
+
"type" => "array"
+
}
+
},
+
"required" => ["codes"],
+
"type" => "object"
+
}
+
},
+
"parameters" => %{
+
"properties" => %{
+
"createAvailable" => %{
+
"default" => true,
+
"description" =>
+
"Controls whether any new 'earned' but not 'created' invites should be created.",
+
"type" => "boolean"
+
},
+
"includeUsed" => %{"default" => true, "type" => "boolean"}
+
},
+
"type" => "params"
+
},
+
"type" => "query"
+
}
+
},
+
"id" => "com.atproto.server.getAccountInviteCodes",
+
"lexicon" => 1
+
})
+
end
+52
lib/atproto/com/atproto/server/getServiceAuth.ex
···
+
defmodule Com.Atproto.Server.GetServiceAuth do
+
use Atex.Lexicon
+
+
deflexicon(%{
+
"defs" => %{
+
"main" => %{
+
"description" =>
+
"Get a signed token on behalf of the requesting DID for the requested service.",
+
"errors" => [
+
%{
+
"description" =>
+
"Indicates that the requested expiration date is not a valid. May be in the past or may be reliant on the requested scopes.",
+
"name" => "BadExpiration"
+
}
+
],
+
"output" => %{
+
"encoding" => "application/json",
+
"schema" => %{
+
"properties" => %{"token" => %{"type" => "string"}},
+
"required" => ["token"],
+
"type" => "object"
+
}
+
},
+
"parameters" => %{
+
"properties" => %{
+
"aud" => %{
+
"description" =>
+
"The DID of the service that the token will be used to authenticate with",
+
"format" => "did",
+
"type" => "string"
+
},
+
"exp" => %{
+
"description" =>
+
"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.",
+
"type" => "integer"
+
},
+
"lxm" => %{
+
"description" => "Lexicon (XRPC) method to bind the requested token to",
+
"format" => "nsid",
+
"type" => "string"
+
}
+
},
+
"required" => ["aud"],
+
"type" => "params"
+
},
+
"type" => "query"
+
}
+
},
+
"id" => "com.atproto.server.getServiceAuth",
+
"lexicon" => 1
+
})
+
end
+36
lib/atproto/com/atproto/server/getSession.ex
···
+
defmodule Com.Atproto.Server.GetSession do
+
use Atex.Lexicon
+
+
deflexicon(%{
+
"defs" => %{
+
"main" => %{
+
"description" => "Get information about the current auth session. Requires auth.",
+
"output" => %{
+
"encoding" => "application/json",
+
"schema" => %{
+
"properties" => %{
+
"active" => %{"type" => "boolean"},
+
"did" => %{"format" => "did", "type" => "string"},
+
"didDoc" => %{"type" => "unknown"},
+
"email" => %{"type" => "string"},
+
"emailAuthFactor" => %{"type" => "boolean"},
+
"emailConfirmed" => %{"type" => "boolean"},
+
"handle" => %{"format" => "handle", "type" => "string"},
+
"status" => %{
+
"description" =>
+
"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.",
+
"knownValues" => ["takendown", "suspended", "deactivated"],
+
"type" => "string"
+
}
+
},
+
"required" => ["handle", "did"],
+
"type" => "object"
+
}
+
},
+
"type" => "query"
+
}
+
},
+
"id" => "com.atproto.server.getSession",
+
"lexicon" => 1
+
})
+
end
+37
lib/atproto/com/atproto/server/listAppPasswords.ex
···
+
defmodule Com.Atproto.Server.ListAppPasswords do
+
use Atex.Lexicon
+
+
deflexicon(%{
+
"defs" => %{
+
"appPassword" => %{
+
"properties" => %{
+
"createdAt" => %{"format" => "datetime", "type" => "string"},
+
"name" => %{"type" => "string"},
+
"privileged" => %{"type" => "boolean"}
+
},
+
"required" => ["name", "createdAt"],
+
"type" => "object"
+
},
+
"main" => %{
+
"description" => "List all App Passwords.",
+
"errors" => [%{"name" => "AccountTakedown"}],
+
"output" => %{
+
"encoding" => "application/json",
+
"schema" => %{
+
"properties" => %{
+
"passwords" => %{
+
"items" => %{"ref" => "#appPassword", "type" => "ref"},
+
"type" => "array"
+
}
+
},
+
"required" => ["passwords"],
+
"type" => "object"
+
}
+
},
+
"type" => "query"
+
}
+
},
+
"id" => "com.atproto.server.listAppPasswords",
+
"lexicon" => 1
+
})
+
end
+37
lib/atproto/com/atproto/server/refreshSession.ex
···
+
defmodule Com.Atproto.Server.RefreshSession do
+
use Atex.Lexicon
+
+
deflexicon(%{
+
"defs" => %{
+
"main" => %{
+
"description" =>
+
"Refresh an authentication session. Requires auth using the 'refreshJwt' (not the 'accessJwt').",
+
"errors" => [%{"name" => "AccountTakedown"}],
+
"output" => %{
+
"encoding" => "application/json",
+
"schema" => %{
+
"properties" => %{
+
"accessJwt" => %{"type" => "string"},
+
"active" => %{"type" => "boolean"},
+
"did" => %{"format" => "did", "type" => "string"},
+
"didDoc" => %{"type" => "unknown"},
+
"handle" => %{"format" => "handle", "type" => "string"},
+
"refreshJwt" => %{"type" => "string"},
+
"status" => %{
+
"description" =>
+
"Hosting status of the account. If not specified, then assume 'active'.",
+
"knownValues" => ["takendown", "suspended", "deactivated"],
+
"type" => "string"
+
}
+
},
+
"required" => ["accessJwt", "refreshJwt", "handle", "did"],
+
"type" => "object"
+
}
+
},
+
"type" => "procedure"
+
}
+
},
+
"id" => "com.atproto.server.refreshSession",
+
"lexicon" => 1
+
})
+
end
+14
lib/atproto/com/atproto/server/requestAccountDelete.ex
···
+
defmodule Com.Atproto.Server.RequestAccountDelete do
+
use Atex.Lexicon
+
+
deflexicon(%{
+
"defs" => %{
+
"main" => %{
+
"description" => "Initiate a user account deletion via email.",
+
"type" => "procedure"
+
}
+
},
+
"id" => "com.atproto.server.requestAccountDelete",
+
"lexicon" => 1
+
})
+
end
+14
lib/atproto/com/atproto/server/requestEmailConfirmation.ex
···
+
defmodule Com.Atproto.Server.RequestEmailConfirmation do
+
use Atex.Lexicon
+
+
deflexicon(%{
+
"defs" => %{
+
"main" => %{
+
"description" => "Request an email with a code to confirm ownership of email.",
+
"type" => "procedure"
+
}
+
},
+
"id" => "com.atproto.server.requestEmailConfirmation",
+
"lexicon" => 1
+
})
+
end
+22
lib/atproto/com/atproto/server/requestEmailUpdate.ex
···
+
defmodule Com.Atproto.Server.RequestEmailUpdate do
+
use Atex.Lexicon
+
+
deflexicon(%{
+
"defs" => %{
+
"main" => %{
+
"description" => "Request a token in order to update email.",
+
"output" => %{
+
"encoding" => "application/json",
+
"schema" => %{
+
"properties" => %{"tokenRequired" => %{"type" => "boolean"}},
+
"required" => ["tokenRequired"],
+
"type" => "object"
+
}
+
},
+
"type" => "procedure"
+
}
+
},
+
"id" => "com.atproto.server.requestEmailUpdate",
+
"lexicon" => 1
+
})
+
end
+22
lib/atproto/com/atproto/server/requestPasswordReset.ex
···
+
defmodule Com.Atproto.Server.RequestPasswordReset do
+
use Atex.Lexicon
+
+
deflexicon(%{
+
"defs" => %{
+
"main" => %{
+
"description" => "Initiate a user account password reset via email.",
+
"input" => %{
+
"encoding" => "application/json",
+
"schema" => %{
+
"properties" => %{"email" => %{"type" => "string"}},
+
"required" => ["email"],
+
"type" => "object"
+
}
+
},
+
"type" => "procedure"
+
}
+
},
+
"id" => "com.atproto.server.requestPasswordReset",
+
"lexicon" => 1
+
})
+
end
+42
lib/atproto/com/atproto/server/reserveSigningKey.ex
···
+
defmodule Com.Atproto.Server.ReserveSigningKey do
+
use Atex.Lexicon
+
+
deflexicon(%{
+
"defs" => %{
+
"main" => %{
+
"description" =>
+
"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.",
+
"input" => %{
+
"encoding" => "application/json",
+
"schema" => %{
+
"properties" => %{
+
"did" => %{
+
"description" => "The DID to reserve a key for.",
+
"format" => "did",
+
"type" => "string"
+
}
+
},
+
"type" => "object"
+
}
+
},
+
"output" => %{
+
"encoding" => "application/json",
+
"schema" => %{
+
"properties" => %{
+
"signingKey" => %{
+
"description" =>
+
"The public key for the reserved signing key, in did:key serialization.",
+
"type" => "string"
+
}
+
},
+
"required" => ["signingKey"],
+
"type" => "object"
+
}
+
},
+
"type" => "procedure"
+
}
+
},
+
"id" => "com.atproto.server.reserveSigningKey",
+
"lexicon" => 1
+
})
+
end
+26
lib/atproto/com/atproto/server/resetPassword.ex
···
+
defmodule Com.Atproto.Server.ResetPassword do
+
use Atex.Lexicon
+
+
deflexicon(%{
+
"defs" => %{
+
"main" => %{
+
"description" => "Reset a user account password using a token.",
+
"errors" => [%{"name" => "ExpiredToken"}, %{"name" => "InvalidToken"}],
+
"input" => %{
+
"encoding" => "application/json",
+
"schema" => %{
+
"properties" => %{
+
"password" => %{"type" => "string"},
+
"token" => %{"type" => "string"}
+
},
+
"required" => ["token", "password"],
+
"type" => "object"
+
}
+
},
+
"type" => "procedure"
+
}
+
},
+
"id" => "com.atproto.server.resetPassword",
+
"lexicon" => 1
+
})
+
end
+22
lib/atproto/com/atproto/server/revokeAppPassword.ex
···
+
defmodule Com.Atproto.Server.RevokeAppPassword do
+
use Atex.Lexicon
+
+
deflexicon(%{
+
"defs" => %{
+
"main" => %{
+
"description" => "Revoke an App Password by name.",
+
"input" => %{
+
"encoding" => "application/json",
+
"schema" => %{
+
"properties" => %{"name" => %{"type" => "string"}},
+
"required" => ["name"],
+
"type" => "object"
+
}
+
},
+
"type" => "procedure"
+
}
+
},
+
"id" => "com.atproto.server.revokeAppPassword",
+
"lexicon" => 1
+
})
+
end
+35
lib/atproto/com/atproto/server/updateEmail.ex
···
+
defmodule Com.Atproto.Server.UpdateEmail do
+
use Atex.Lexicon
+
+
deflexicon(%{
+
"defs" => %{
+
"main" => %{
+
"description" => "Update an account's email.",
+
"errors" => [
+
%{"name" => "ExpiredToken"},
+
%{"name" => "InvalidToken"},
+
%{"name" => "TokenRequired"}
+
],
+
"input" => %{
+
"encoding" => "application/json",
+
"schema" => %{
+
"properties" => %{
+
"email" => %{"type" => "string"},
+
"emailAuthFactor" => %{"type" => "boolean"},
+
"token" => %{
+
"description" =>
+
"Requires a token from com.atproto.sever.requestEmailUpdate if the account's email has been confirmed.",
+
"type" => "string"
+
}
+
},
+
"required" => ["email"],
+
"type" => "object"
+
}
+
},
+
"type" => "procedure"
+
}
+
},
+
"id" => "com.atproto.server.updateEmail",
+
"lexicon" => 1
+
})
+
end
+14
lib/atproto/com/atproto/sync/defs.ex
···
+
defmodule Com.Atproto.Sync.Defs do
+
use Atex.Lexicon
+
+
deflexicon(%{
+
"defs" => %{
+
"hostStatus" => %{
+
"knownValues" => ["active", "idle", "offline", "throttled", "banned"],
+
"type" => "string"
+
}
+
},
+
"id" => "com.atproto.sync.defs",
+
"lexicon" => 1
+
})
+
end
+39
lib/atproto/com/atproto/sync/getBlob.ex
···
+
defmodule Com.Atproto.Sync.GetBlob do
+
use Atex.Lexicon
+
+
deflexicon(%{
+
"defs" => %{
+
"main" => %{
+
"description" =>
+
"Get a blob associated with a given account. Returns the full blob as originally uploaded. Does not require auth; implemented by PDS.",
+
"errors" => [
+
%{"name" => "BlobNotFound"},
+
%{"name" => "RepoNotFound"},
+
%{"name" => "RepoTakendown"},
+
%{"name" => "RepoSuspended"},
+
%{"name" => "RepoDeactivated"}
+
],
+
"output" => %{"encoding" => "*/*"},
+
"parameters" => %{
+
"properties" => %{
+
"cid" => %{
+
"description" => "The CID of the blob to fetch",
+
"format" => "cid",
+
"type" => "string"
+
},
+
"did" => %{
+
"description" => "The DID of the account.",
+
"format" => "did",
+
"type" => "string"
+
}
+
},
+
"required" => ["did", "cid"],
+
"type" => "params"
+
},
+
"type" => "query"
+
}
+
},
+
"id" => "com.atproto.sync.getBlob",
+
"lexicon" => 1
+
})
+
end
+38
lib/atproto/com/atproto/sync/getBlocks.ex
···
+
defmodule Com.Atproto.Sync.GetBlocks do
+
use Atex.Lexicon
+
+
deflexicon(%{
+
"defs" => %{
+
"main" => %{
+
"description" =>
+
"Get data blocks from a given repo, by CID. For example, intermediate MST nodes, or records. Does not require auth; implemented by PDS.",
+
"errors" => [
+
%{"name" => "BlockNotFound"},
+
%{"name" => "RepoNotFound"},
+
%{"name" => "RepoTakendown"},
+
%{"name" => "RepoSuspended"},
+
%{"name" => "RepoDeactivated"}
+
],
+
"output" => %{"encoding" => "application/vnd.ipld.car"},
+
"parameters" => %{
+
"properties" => %{
+
"cids" => %{
+
"items" => %{"format" => "cid", "type" => "string"},
+
"type" => "array"
+
},
+
"did" => %{
+
"description" => "The DID of the repo.",
+
"format" => "did",
+
"type" => "string"
+
}
+
},
+
"required" => ["did", "cids"],
+
"type" => "params"
+
},
+
"type" => "query"
+
}
+
},
+
"id" => "com.atproto.sync.getBlocks",
+
"lexicon" => 1
+
})
+
end
+26
lib/atproto/com/atproto/sync/getCheckout.ex
···
+
defmodule Com.Atproto.Sync.GetCheckout do
+
use Atex.Lexicon
+
+
deflexicon(%{
+
"defs" => %{
+
"main" => %{
+
"description" => "DEPRECATED - please use com.atproto.sync.getRepo instead",
+
"output" => %{"encoding" => "application/vnd.ipld.car"},
+
"parameters" => %{
+
"properties" => %{
+
"did" => %{
+
"description" => "The DID of the repo.",
+
"format" => "did",
+
"type" => "string"
+
}
+
},
+
"required" => ["did"],
+
"type" => "params"
+
},
+
"type" => "query"
+
}
+
},
+
"id" => "com.atproto.sync.getCheckout",
+
"lexicon" => 1
+
})
+
end
+34
lib/atproto/com/atproto/sync/getHead.ex
···
+
defmodule Com.Atproto.Sync.GetHead do
+
use Atex.Lexicon
+
+
deflexicon(%{
+
"defs" => %{
+
"main" => %{
+
"description" => "DEPRECATED - please use com.atproto.sync.getLatestCommit instead",
+
"errors" => [%{"name" => "HeadNotFound"}],
+
"output" => %{
+
"encoding" => "application/json",
+
"schema" => %{
+
"properties" => %{"root" => %{"format" => "cid", "type" => "string"}},
+
"required" => ["root"],
+
"type" => "object"
+
}
+
},
+
"parameters" => %{
+
"properties" => %{
+
"did" => %{
+
"description" => "The DID of the repo.",
+
"format" => "did",
+
"type" => "string"
+
}
+
},
+
"required" => ["did"],
+
"type" => "params"
+
},
+
"type" => "query"
+
}
+
},
+
"id" => "com.atproto.sync.getHead",
+
"lexicon" => 1
+
})
+
end
+50
lib/atproto/com/atproto/sync/getHostStatus.ex
···
+
defmodule Com.Atproto.Sync.GetHostStatus do
+
use Atex.Lexicon
+
+
deflexicon(%{
+
"defs" => %{
+
"main" => %{
+
"description" =>
+
"Returns information about a specified upstream host, as consumed by the server. Implemented by relays.",
+
"errors" => [%{"name" => "HostNotFound"}],
+
"output" => %{
+
"encoding" => "application/json",
+
"schema" => %{
+
"properties" => %{
+
"accountCount" => %{
+
"description" =>
+
"Number of accounts on the server which are associated with the upstream host. Note that the upstream may actually have more accounts.",
+
"type" => "integer"
+
},
+
"hostname" => %{"type" => "string"},
+
"seq" => %{
+
"description" =>
+
"Recent repo stream event sequence number. May be delayed from actual stream processing (eg, persisted cursor not in-memory cursor).",
+
"type" => "integer"
+
},
+
"status" => %{
+
"ref" => "com.atproto.sync.defs#hostStatus",
+
"type" => "ref"
+
}
+
},
+
"required" => ["hostname"],
+
"type" => "object"
+
}
+
},
+
"parameters" => %{
+
"properties" => %{
+
"hostname" => %{
+
"description" => "Hostname of the host (eg, PDS or relay) being queried.",
+
"type" => "string"
+
}
+
},
+
"required" => ["hostname"],
+
"type" => "params"
+
},
+
"type" => "query"
+
}
+
},
+
"id" => "com.atproto.sync.getHostStatus",
+
"lexicon" => 1
+
})
+
end
+43
lib/atproto/com/atproto/sync/getLatestCommit.ex
···
+
defmodule Com.Atproto.Sync.GetLatestCommit do
+
use Atex.Lexicon
+
+
deflexicon(%{
+
"defs" => %{
+
"main" => %{
+
"description" =>
+
"Get the current commit CID & revision of the specified repo. Does not require auth.",
+
"errors" => [
+
%{"name" => "RepoNotFound"},
+
%{"name" => "RepoTakendown"},
+
%{"name" => "RepoSuspended"},
+
%{"name" => "RepoDeactivated"}
+
],
+
"output" => %{
+
"encoding" => "application/json",
+
"schema" => %{
+
"properties" => %{
+
"cid" => %{"format" => "cid", "type" => "string"},
+
"rev" => %{"format" => "tid", "type" => "string"}
+
},
+
"required" => ["cid", "rev"],
+
"type" => "object"
+
}
+
},
+
"parameters" => %{
+
"properties" => %{
+
"did" => %{
+
"description" => "The DID of the repo.",
+
"format" => "did",
+
"type" => "string"
+
}
+
},
+
"required" => ["did"],
+
"type" => "params"
+
},
+
"type" => "query"
+
}
+
},
+
"id" => "com.atproto.sync.getLatestCommit",
+
"lexicon" => 1
+
})
+
end
+40
lib/atproto/com/atproto/sync/getRecord.ex
···
+
defmodule Com.Atproto.Sync.GetRecord do
+
use Atex.Lexicon
+
+
deflexicon(%{
+
"defs" => %{
+
"main" => %{
+
"description" =>
+
"Get data blocks needed to prove the existence or non-existence of record in the current version of repo. Does not require auth.",
+
"errors" => [
+
%{"name" => "RecordNotFound"},
+
%{"name" => "RepoNotFound"},
+
%{"name" => "RepoTakendown"},
+
%{"name" => "RepoSuspended"},
+
%{"name" => "RepoDeactivated"}
+
],
+
"output" => %{"encoding" => "application/vnd.ipld.car"},
+
"parameters" => %{
+
"properties" => %{
+
"collection" => %{"format" => "nsid", "type" => "string"},
+
"did" => %{
+
"description" => "The DID of the repo.",
+
"format" => "did",
+
"type" => "string"
+
},
+
"rkey" => %{
+
"description" => "Record Key",
+
"format" => "record-key",
+
"type" => "string"
+
}
+
},
+
"required" => ["did", "collection", "rkey"],
+
"type" => "params"
+
},
+
"type" => "query"
+
}
+
},
+
"id" => "com.atproto.sync.getRecord",
+
"lexicon" => 1
+
})
+
end
+38
lib/atproto/com/atproto/sync/getRepo.ex
···
+
defmodule Com.Atproto.Sync.GetRepo do
+
use Atex.Lexicon
+
+
deflexicon(%{
+
"defs" => %{
+
"main" => %{
+
"description" =>
+
"Download a repository export as CAR file. Optionally only a 'diff' since a previous revision. Does not require auth; implemented by PDS.",
+
"errors" => [
+
%{"name" => "RepoNotFound"},
+
%{"name" => "RepoTakendown"},
+
%{"name" => "RepoSuspended"},
+
%{"name" => "RepoDeactivated"}
+
],
+
"output" => %{"encoding" => "application/vnd.ipld.car"},
+
"parameters" => %{
+
"properties" => %{
+
"did" => %{
+
"description" => "The DID of the repo.",
+
"format" => "did",
+
"type" => "string"
+
},
+
"since" => %{
+
"description" => "The revision ('rev') of the repo to create a diff from.",
+
"format" => "tid",
+
"type" => "string"
+
}
+
},
+
"required" => ["did"],
+
"type" => "params"
+
},
+
"type" => "query"
+
}
+
},
+
"id" => "com.atproto.sync.getRepo",
+
"lexicon" => 1
+
})
+
end
+56
lib/atproto/com/atproto/sync/getRepoStatus.ex
···
+
defmodule Com.Atproto.Sync.GetRepoStatus do
+
use Atex.Lexicon
+
+
deflexicon(%{
+
"defs" => %{
+
"main" => %{
+
"description" =>
+
"Get the hosting status for a repository, on this server. Expected to be implemented by PDS and Relay.",
+
"errors" => [%{"name" => "RepoNotFound"}],
+
"output" => %{
+
"encoding" => "application/json",
+
"schema" => %{
+
"properties" => %{
+
"active" => %{"type" => "boolean"},
+
"did" => %{"format" => "did", "type" => "string"},
+
"rev" => %{
+
"description" => "Optional field, the current rev of the repo, if active=true",
+
"format" => "tid",
+
"type" => "string"
+
},
+
"status" => %{
+
"description" =>
+
"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.",
+
"knownValues" => [
+
"takendown",
+
"suspended",
+
"deleted",
+
"deactivated",
+
"desynchronized",
+
"throttled"
+
],
+
"type" => "string"
+
}
+
},
+
"required" => ["did", "active"],
+
"type" => "object"
+
}
+
},
+
"parameters" => %{
+
"properties" => %{
+
"did" => %{
+
"description" => "The DID of the repo.",
+
"format" => "did",
+
"type" => "string"
+
}
+
},
+
"required" => ["did"],
+
"type" => "params"
+
},
+
"type" => "query"
+
}
+
},
+
"id" => "com.atproto.sync.getRepoStatus",
+
"lexicon" => 1
+
})
+
end
+58
lib/atproto/com/atproto/sync/listBlobs.ex
···
+
defmodule Com.Atproto.Sync.ListBlobs do
+
use Atex.Lexicon
+
+
deflexicon(%{
+
"defs" => %{
+
"main" => %{
+
"description" =>
+
"List blob CIDs for an account, since some repo revision. Does not require auth; implemented by PDS.",
+
"errors" => [
+
%{"name" => "RepoNotFound"},
+
%{"name" => "RepoTakendown"},
+
%{"name" => "RepoSuspended"},
+
%{"name" => "RepoDeactivated"}
+
],
+
"output" => %{
+
"encoding" => "application/json",
+
"schema" => %{
+
"properties" => %{
+
"cids" => %{
+
"items" => %{"format" => "cid", "type" => "string"},
+
"type" => "array"
+
},
+
"cursor" => %{"type" => "string"}
+
},
+
"required" => ["cids"],
+
"type" => "object"
+
}
+
},
+
"parameters" => %{
+
"properties" => %{
+
"cursor" => %{"type" => "string"},
+
"did" => %{
+
"description" => "The DID of the repo.",
+
"format" => "did",
+
"type" => "string"
+
},
+
"limit" => %{
+
"default" => 500,
+
"maximum" => 1000,
+
"minimum" => 1,
+
"type" => "integer"
+
},
+
"since" => %{
+
"description" => "Optional revision of the repo to list blobs since.",
+
"format" => "tid",
+
"type" => "string"
+
}
+
},
+
"required" => ["did"],
+
"type" => "params"
+
},
+
"type" => "query"
+
}
+
},
+
"id" => "com.atproto.sync.listBlobs",
+
"lexicon" => 1
+
})
+
end
+63
lib/atproto/com/atproto/sync/listHosts.ex
···
+
defmodule Com.Atproto.Sync.ListHosts do
+
use Atex.Lexicon
+
+
deflexicon(%{
+
"defs" => %{
+
"host" => %{
+
"properties" => %{
+
"accountCount" => %{"type" => "integer"},
+
"hostname" => %{
+
"description" => "hostname of server; not a URL (no scheme)",
+
"type" => "string"
+
},
+
"seq" => %{
+
"description" =>
+
"Recent repo stream event sequence number. May be delayed from actual stream processing (eg, persisted cursor not in-memory cursor).",
+
"type" => "integer"
+
},
+
"status" => %{
+
"ref" => "com.atproto.sync.defs#hostStatus",
+
"type" => "ref"
+
}
+
},
+
"required" => ["hostname"],
+
"type" => "object"
+
},
+
"main" => %{
+
"description" =>
+
"Enumerates upstream hosts (eg, PDS or relay instances) that this service consumes from. Implemented by relays.",
+
"output" => %{
+
"encoding" => "application/json",
+
"schema" => %{
+
"properties" => %{
+
"cursor" => %{"type" => "string"},
+
"hosts" => %{
+
"description" =>
+
"Sort order is not formally specified. Recommended order is by time host was first seen by the server, with oldest first.",
+
"items" => %{"ref" => "#host", "type" => "ref"},
+
"type" => "array"
+
}
+
},
+
"required" => ["hosts"],
+
"type" => "object"
+
}
+
},
+
"parameters" => %{
+
"properties" => %{
+
"cursor" => %{"type" => "string"},
+
"limit" => %{
+
"default" => 200,
+
"maximum" => 1000,
+
"minimum" => 1,
+
"type" => "integer"
+
}
+
},
+
"type" => "params"
+
},
+
"type" => "query"
+
}
+
},
+
"id" => "com.atproto.sync.listHosts",
+
"lexicon" => 1
+
})
+
end
+68
lib/atproto/com/atproto/sync/listRepos.ex
···
+
defmodule Com.Atproto.Sync.ListRepos do
+
use Atex.Lexicon
+
+
deflexicon(%{
+
"defs" => %{
+
"main" => %{
+
"description" =>
+
"Enumerates all the DID, rev, and commit CID for all repos hosted by this service. Does not require auth; implemented by PDS and Relay.",
+
"output" => %{
+
"encoding" => "application/json",
+
"schema" => %{
+
"properties" => %{
+
"cursor" => %{"type" => "string"},
+
"repos" => %{
+
"items" => %{"ref" => "#repo", "type" => "ref"},
+
"type" => "array"
+
}
+
},
+
"required" => ["repos"],
+
"type" => "object"
+
}
+
},
+
"parameters" => %{
+
"properties" => %{
+
"cursor" => %{"type" => "string"},
+
"limit" => %{
+
"default" => 500,
+
"maximum" => 1000,
+
"minimum" => 1,
+
"type" => "integer"
+
}
+
},
+
"type" => "params"
+
},
+
"type" => "query"
+
},
+
"repo" => %{
+
"properties" => %{
+
"active" => %{"type" => "boolean"},
+
"did" => %{"format" => "did", "type" => "string"},
+
"head" => %{
+
"description" => "Current repo commit CID",
+
"format" => "cid",
+
"type" => "string"
+
},
+
"rev" => %{"format" => "tid", "type" => "string"},
+
"status" => %{
+
"description" =>
+
"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.",
+
"knownValues" => [
+
"takendown",
+
"suspended",
+
"deleted",
+
"deactivated",
+
"desynchronized",
+
"throttled"
+
],
+
"type" => "string"
+
}
+
},
+
"required" => ["did", "head", "rev"],
+
"type" => "object"
+
}
+
},
+
"id" => "com.atproto.sync.listRepos",
+
"lexicon" => 1
+
})
+
end
+50
lib/atproto/com/atproto/sync/listReposByCollection.ex
···
+
defmodule Com.Atproto.Sync.ListReposByCollection do
+
use Atex.Lexicon
+
+
deflexicon(%{
+
"defs" => %{
+
"main" => %{
+
"description" =>
+
"Enumerates all the DIDs which have records with the given collection NSID.",
+
"output" => %{
+
"encoding" => "application/json",
+
"schema" => %{
+
"properties" => %{
+
"cursor" => %{"type" => "string"},
+
"repos" => %{
+
"items" => %{"ref" => "#repo", "type" => "ref"},
+
"type" => "array"
+
}
+
},
+
"required" => ["repos"],
+
"type" => "object"
+
}
+
},
+
"parameters" => %{
+
"properties" => %{
+
"collection" => %{"format" => "nsid", "type" => "string"},
+
"cursor" => %{"type" => "string"},
+
"limit" => %{
+
"default" => 500,
+
"description" =>
+
"Maximum size of response set. Recommend setting a large maximum (1000+) when enumerating large DID lists.",
+
"maximum" => 2000,
+
"minimum" => 1,
+
"type" => "integer"
+
}
+
},
+
"required" => ["collection"],
+
"type" => "params"
+
},
+
"type" => "query"
+
},
+
"repo" => %{
+
"properties" => %{"did" => %{"format" => "did", "type" => "string"}},
+
"required" => ["did"],
+
"type" => "object"
+
}
+
},
+
"id" => "com.atproto.sync.listReposByCollection",
+
"lexicon" => 1
+
})
+
end
+29
lib/atproto/com/atproto/sync/notifyOfUpdate.ex
···
+
defmodule Com.Atproto.Sync.NotifyOfUpdate do
+
use Atex.Lexicon
+
+
deflexicon(%{
+
"defs" => %{
+
"main" => %{
+
"description" =>
+
"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",
+
"input" => %{
+
"encoding" => "application/json",
+
"schema" => %{
+
"properties" => %{
+
"hostname" => %{
+
"description" =>
+
"Hostname of the current service (usually a PDS) that is notifying of update.",
+
"type" => "string"
+
}
+
},
+
"required" => ["hostname"],
+
"type" => "object"
+
}
+
},
+
"type" => "procedure"
+
}
+
},
+
"id" => "com.atproto.sync.notifyOfUpdate",
+
"lexicon" => 1
+
})
+
end
+30
lib/atproto/com/atproto/sync/requestCrawl.ex
···
+
defmodule Com.Atproto.Sync.RequestCrawl do
+
use Atex.Lexicon
+
+
deflexicon(%{
+
"defs" => %{
+
"main" => %{
+
"description" =>
+
"Request a service to persistently crawl hosted repos. Expected use is new PDS instances declaring their existence to Relays. Does not require auth.",
+
"errors" => [%{"name" => "HostBanned"}],
+
"input" => %{
+
"encoding" => "application/json",
+
"schema" => %{
+
"properties" => %{
+
"hostname" => %{
+
"description" =>
+
"Hostname of the current service (eg, PDS) that is requesting to be crawled.",
+
"type" => "string"
+
}
+
},
+
"required" => ["hostname"],
+
"type" => "object"
+
}
+
},
+
"type" => "procedure"
+
}
+
},
+
"id" => "com.atproto.sync.requestCrawl",
+
"lexicon" => 1
+
})
+
end
+237
lib/atproto/com/atproto/sync/subscribeRepos.ex
···
+
defmodule Com.Atproto.Sync.SubscribeRepos do
+
use Atex.Lexicon
+
+
deflexicon(%{
+
"defs" => %{
+
"account" => %{
+
"description" =>
+
"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.",
+
"properties" => %{
+
"active" => %{
+
"description" =>
+
"Indicates that the account has a repository which can be fetched from the host that emitted this event.",
+
"type" => "boolean"
+
},
+
"did" => %{"format" => "did", "type" => "string"},
+
"seq" => %{"type" => "integer"},
+
"status" => %{
+
"description" =>
+
"If active=false, this optional field indicates a reason for why the account is not active.",
+
"knownValues" => [
+
"takendown",
+
"suspended",
+
"deleted",
+
"deactivated",
+
"desynchronized",
+
"throttled"
+
],
+
"type" => "string"
+
},
+
"time" => %{"format" => "datetime", "type" => "string"}
+
},
+
"required" => ["seq", "did", "time", "active"],
+
"type" => "object"
+
},
+
"commit" => %{
+
"description" =>
+
"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.",
+
"nullable" => ["since"],
+
"properties" => %{
+
"blobs" => %{
+
"items" => %{
+
"description" =>
+
"DEPRECATED -- will soon always be empty. List of new blobs (by CID) referenced by records in this commit.",
+
"type" => "cid-link"
+
},
+
"type" => "array"
+
},
+
"blocks" => %{
+
"description" =>
+
"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.",
+
"maxLength" => 2_000_000,
+
"type" => "bytes"
+
},
+
"commit" => %{
+
"description" => "Repo commit object CID.",
+
"type" => "cid-link"
+
},
+
"ops" => %{
+
"items" => %{
+
"description" =>
+
"List of repo mutation operations in this commit (eg, records created, updated, or deleted).",
+
"ref" => "#repoOp",
+
"type" => "ref"
+
},
+
"maxLength" => 200,
+
"type" => "array"
+
},
+
"prevData" => %{
+
"description" =>
+
"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.",
+
"type" => "cid-link"
+
},
+
"rebase" => %{
+
"description" => "DEPRECATED -- unused",
+
"type" => "boolean"
+
},
+
"repo" => %{
+
"description" =>
+
"The repo this event comes from. Note that all other message types name this field 'did'.",
+
"format" => "did",
+
"type" => "string"
+
},
+
"rev" => %{
+
"description" =>
+
"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.",
+
"format" => "tid",
+
"type" => "string"
+
},
+
"seq" => %{
+
"description" => "The stream sequence number of this message.",
+
"type" => "integer"
+
},
+
"since" => %{
+
"description" => "The rev of the last emitted commit from this repo (if any).",
+
"format" => "tid",
+
"type" => "string"
+
},
+
"time" => %{
+
"description" => "Timestamp of when this message was originally broadcast.",
+
"format" => "datetime",
+
"type" => "string"
+
},
+
"tooBig" => %{
+
"description" =>
+
"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.",
+
"type" => "boolean"
+
}
+
},
+
"required" => [
+
"seq",
+
"rebase",
+
"tooBig",
+
"repo",
+
"commit",
+
"rev",
+
"since",
+
"blocks",
+
"ops",
+
"blobs",
+
"time"
+
],
+
"type" => "object"
+
},
+
"identity" => %{
+
"description" =>
+
"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.",
+
"properties" => %{
+
"did" => %{"format" => "did", "type" => "string"},
+
"handle" => %{
+
"description" =>
+
"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.",
+
"format" => "handle",
+
"type" => "string"
+
},
+
"seq" => %{"type" => "integer"},
+
"time" => %{"format" => "datetime", "type" => "string"}
+
},
+
"required" => ["seq", "did", "time"],
+
"type" => "object"
+
},
+
"info" => %{
+
"properties" => %{
+
"message" => %{"type" => "string"},
+
"name" => %{"knownValues" => ["OutdatedCursor"], "type" => "string"}
+
},
+
"required" => ["name"],
+
"type" => "object"
+
},
+
"main" => %{
+
"description" =>
+
"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.",
+
"errors" => [
+
%{"name" => "FutureCursor"},
+
%{
+
"description" =>
+
"If the consumer of the stream can not keep up with events, and a backlog gets too large, the server will drop the connection.",
+
"name" => "ConsumerTooSlow"
+
}
+
],
+
"message" => %{
+
"schema" => %{
+
"refs" => ["#commit", "#sync", "#identity", "#account", "#info"],
+
"type" => "union"
+
}
+
},
+
"parameters" => %{
+
"properties" => %{
+
"cursor" => %{
+
"description" => "The last known event seq number to backfill from.",
+
"type" => "integer"
+
}
+
},
+
"type" => "params"
+
},
+
"type" => "subscription"
+
},
+
"repoOp" => %{
+
"description" => "A repo operation, ie a mutation of a single record.",
+
"nullable" => ["cid"],
+
"properties" => %{
+
"action" => %{
+
"knownValues" => ["create", "update", "delete"],
+
"type" => "string"
+
},
+
"cid" => %{
+
"description" => "For creates and updates, the new record CID. For deletions, null.",
+
"type" => "cid-link"
+
},
+
"path" => %{"type" => "string"},
+
"prev" => %{
+
"description" =>
+
"For updates and deletes, the previous record CID (required for inductive firehose). For creations, field should not be defined.",
+
"type" => "cid-link"
+
}
+
},
+
"required" => ["action", "path", "cid"],
+
"type" => "object"
+
},
+
"sync" => %{
+
"description" =>
+
"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.",
+
"properties" => %{
+
"blocks" => %{
+
"description" =>
+
"CAR file containing the commit, as a block. The CAR header must include the commit block CID as the first 'root'.",
+
"maxLength" => 10000,
+
"type" => "bytes"
+
},
+
"did" => %{
+
"description" =>
+
"The account this repo event corresponds to. Must match that in the commit object.",
+
"format" => "did",
+
"type" => "string"
+
},
+
"rev" => %{
+
"description" =>
+
"The rev of the commit. This value must match that in the commit object.",
+
"type" => "string"
+
},
+
"seq" => %{
+
"description" => "The stream sequence number of this message.",
+
"type" => "integer"
+
},
+
"time" => %{
+
"description" => "Timestamp of when this message was originally broadcast.",
+
"format" => "datetime",
+
"type" => "string"
+
}
+
},
+
"required" => ["seq", "did", "blocks", "rev", "time"],
+
"type" => "object"
+
}
+
},
+
"id" => "com.atproto.sync.subscribeRepos",
+
"lexicon" => 1
+
})
+
end
-158
lib/aturi.ex
···
-
defmodule Atex.AtURI do
-
@moduledoc """
-
Struct and helper functions for manipulating `at://` URIs, which identify
-
specific records within the AT Protocol. For more information on the URI
-
scheme, refer to the ATProto spec: https://atproto.com/specs/at-uri-scheme.
-
-
This module only supports the restricted URI syntax used for the Lexicon
-
`at-uri` type, with no support for query strings or fragments. If/when the
-
full syntax gets widespread use, this module will expand to accomodate them.
-
-
Both URIs using DIDs and handles ("example.com") are supported.
-
"""
-
-
use TypedStruct
-
-
@did ~S"did:(?:plc|web):[a-zA-Z0-9._:%-]*[a-zA-Z0-9._-]"
-
@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])?"
-
@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})?)"
-
-
@authority "(?<authority>(?:#{@did})|(?:#{@handle}))"
-
@collection "(?<collection>#{@nsid})"
-
@rkey "(?<rkey>[a-zA-Z0-9.-_:~]{1,512})"
-
-
@re ~r"^at://#{@authority}(?:/#{@collection}(?:/#{@rkey})?)?$"
-
-
typedstruct do
-
field :authority, String.t(), enforce: true
-
field :collection, String.t() | nil
-
field :rkey, String.t() | nil
-
end
-
-
@doc """
-
Create a new AtURI struct from a string by matching it against the regex.
-
-
Returns `{:ok, aturi}` if a valid `at://` URI is given, otherwise it will return `:error`.
-
-
## Examples
-
-
iex> Atex.AtURI.new("at://did:plc:44ybard66vv44zksje25o7dz/app.bsky.feed.post/3jwdwj2ctlk26")
-
{:ok, %Atex.AtURI{
-
rkey: "3jwdwj2ctlk26",
-
collection: "app.bsky.feed.post",
-
authority: "did:plc:44ybard66vv44zksje25o7dz"
-
}}
-
-
iex> Atex.AtURI.new("at:invalid/malformed")
-
:error
-
-
Partial URIs pointing to a collection without a record key, or even just a given authority, are also supported:
-
-
iex> Atex.AtURI.new("at://ovyerus.com/sh.comet.v0.feed.track")
-
{:ok, %Atex.AtURI{
-
rkey: nil,
-
collection: "sh.comet.v0.feed.track",
-
authority: "ovyerus.com"
-
}}
-
-
iex> Atex.AtURI.new("at://did:web:comet.sh")
-
{:ok, %Atex.AtURI{
-
rkey: nil,
-
collection: nil,
-
authority: "did:web:comet.sh"
-
}}
-
"""
-
@spec new(String.t()) :: {:ok, t()} | :error
-
def new(string) when is_binary(string) do
-
# TODO: test different ways to get a good error from regex on which part failed match?
-
case Regex.named_captures(@re, string) do
-
%{} = captures -> {:ok, from_named_captures(captures)}
-
nil -> :error
-
end
-
end
-
-
@doc """
-
The same as `new/1` but raises an `ArgumentError` if an invalid string is given.
-
-
## Examples
-
-
iex> Atex.AtURI.new!("at://did:plc:44ybard66vv44zksje25o7dz/app.bsky.feed.post/3jwdwj2ctlk26")
-
%Atex.AtURI{
-
rkey: "3jwdwj2ctlk26",
-
collection: "app.bsky.feed.post",
-
authority: "did:plc:44ybard66vv44zksje25o7dz"
-
}
-
-
iex> Atex.AtURI.new!("at:invalid/malformed")
-
** (ArgumentError) Malformed at:// URI
-
"""
-
@spec new!(String.t()) :: t()
-
def new!(string) when is_binary(string) do
-
case new(string) do
-
{:ok, uri} -> uri
-
:error -> raise ArgumentError, message: "Malformed at:// URI"
-
end
-
end
-
-
@doc """
-
Check if a string is a valid `at://` URI.
-
-
## Examples
-
-
iex> Atex.AtURI.match?("at://did:plc:44ybard66vv44zksje25o7dz/app.bsky.feed.post/3jwdwj2ctlk26")
-
true
-
-
iex> Atex.AtURI.match?("at://did:web:comet.sh")
-
true
-
-
iex> Atex.AtURI.match?("at://ovyerus.com/sh.comet.v0.feed.track")
-
true
-
-
iex> Atex.AtURI.match?("gobbledy gook")
-
false
-
"""
-
@spec match?(String.t()) :: boolean()
-
def match?(string), do: Regex.match?(@re, string)
-
-
@doc """
-
Format an `Atex.AtURI` to the canonical string representation.
-
-
Also available via the `String.Chars` protocol.
-
-
## Examples
-
-
iex> aturi = %Atex.AtURI{
-
...> rkey: "3jwdwj2ctlk26",
-
...> collection: "app.bsky.feed.post",
-
...> authority: "did:plc:44ybard66vv44zksje25o7dz"
-
...> }
-
iex> Atex.AtURI.to_string(aturi)
-
"at://did:plc:44ybard66vv44zksje25o7dz/app.bsky.feed.post/3jwdwj2ctlk26"
-
-
iex> aturi = %Atex.AtURI{authority: "did:web:comet.sh"}
-
iex> to_string(aturi)
-
"at://did:web:comet.sh"
-
"""
-
@spec to_string(t()) :: String.t()
-
def to_string(%__MODULE__{} = uri) do
-
"at://#{uri.authority}/#{uri.collection}/#{uri.rkey}"
-
|> String.trim_trailing("/")
-
end
-
-
defp from_named_captures(%{"authority" => authority, "collection" => "", "rkey" => ""}),
-
do: %__MODULE__{authority: authority}
-
-
defp from_named_captures(%{"authority" => authority, "collection" => collection, "rkey" => ""}),
-
do: %__MODULE__{authority: authority, collection: collection}
-
-
defp from_named_captures(%{
-
"authority" => authority,
-
"collection" => collection,
-
"rkey" => rkey
-
}),
-
do: %__MODULE__{authority: authority, collection: collection, rkey: rkey}
-
end
-
-
defimpl String.Chars, for: Atex.AtURI do
-
def to_string(%Atex.AtURI{} = uri), do: Atex.AtURI.to_string(uri)
-
end
+94
lib/mix/tasks/atex.lexicons.ex
···
+
defmodule Mix.Tasks.Atex.Lexicons do
+
@moduledoc """
+
Generate Elixir modules from AT Protocol lexicons, which can then be used to
+
validate data at runtime.
+
+
AT Protocol lexicons are JSON files that define parts of the AT Protocol data
+
model. This task processes these lexicon files and generates corresponding
+
Elixir modules.
+
+
## Usage
+
+
mix atex.lexicons [OPTIONS] [PATHS]
+
+
## Arguments
+
+
- `PATHS` - List of lexicon files to process. Also supports standard glob
+
syntax for reading many lexicons at once.
+
+
## Options
+
+
- `-o`/`--output` - Output directory for generated modules (default:
+
`lib/atproto`)
+
+
## Examples
+
+
Process all JSON files in the lexicons directory:
+
+
mix atex.lexicons lexicons/**/*.json
+
+
Process specific lexicon files:
+
+
mix atex.lexicons lexicons/com/atproto/repo/*.json lexicons/app/bsky/actor/profile.json
+
+
Generate modules to a custom output directory:
+
+
mix atex.lexicons lexicons/**/*.json --output lib/my_atproto
+
"""
+
@shortdoc "Generate Elixir modules from AT Protocol lexicons."
+
+
use Mix.Task
+
require EEx
+
+
@switches [output: :string]
+
@aliases [o: :output]
+
@template_path Path.expand("../../../priv/templates/lexicon.eex", __DIR__)
+
+
@impl true
+
def run(args) do
+
{options, globs} = OptionParser.parse!(args, switches: @switches, aliases: @aliases)
+
+
output = Keyword.get(options, :output, "lib/atproto")
+
paths = Enum.flat_map(globs, &Path.wildcard/1)
+
+
if length(paths) == 0 do
+
Mix.shell().error("No valid search paths have been provided, aborting.")
+
else
+
Mix.shell().info("Generating modules for lexicons into #{output}")
+
+
Enum.each(paths, fn path ->
+
Mix.shell().info("- #{path}")
+
generate(path, output)
+
end)
+
end
+
end
+
+
# TODO: validate schema?
+
defp generate(input, output) do
+
lexicon =
+
input
+
|> File.read!()
+
|> JSON.decode!()
+
+
if not is_binary(lexicon["id"]) do
+
raise ArgumentError, message: "Malformed lexicon: does not have an `id` field."
+
end
+
+
code = lexicon |> template() |> Code.format_string!() |> Enum.join("")
+
+
file_path =
+
lexicon["id"]
+
|> String.split(".")
+
|> Enum.join("/")
+
|> then(&(&1 <> ".ex"))
+
|> then(&Path.join(output, &1))
+
+
file_path
+
|> Path.dirname()
+
|> File.mkdir_p!()
+
+
File.write!(file_path, code)
+
end
+
+
EEx.function_from_file(:defp, :template, @template_path, [:lexicon])
+
end
+51 -4
mix.exs
···
defmodule Atex.MixProject do
use Mix.Project
+
@version "0.6.0"
+
@github "https://github.com/cometsh/atex"
+
@tangled "https://tangled.sh/@comet.sh/atex"
+
def project do
[
app: :atex,
-
version: "0.1.0",
+
version: @version,
elixir: "~> 1.18",
start_permanent: Mix.env() == :prod,
-
deps: deps()
+
deps: deps(),
+
name: "atex",
+
description: "A set of utilities for working with the AT Protocol in Elixir.",
+
package: package(),
+
docs: docs()
]
end
def application do
[
-
extra_applications: [:logger]
+
extra_applications: [:logger],
+
mod: {Atex.Application, []}
]
end
defp deps do
[
+
{:peri, "~> 0.6"},
+
{:multiformats_ex, "~> 0.2"},
+
{:recase, "~> 0.5"},
+
{:req, "~> 0.5"},
{:typedstruct, "~> 0.5"},
-
{:ex_doc, "~> 0.34", only: :dev, runtime: false, warn_if_outdated: true}
+
{:ex_cldr, "~> 2.42"},
+
{:credo, "~> 1.7", only: [:dev, :test], runtime: false},
+
{:ex_doc, "~> 0.34", only: :dev, runtime: false, warn_if_outdated: true},
+
{:plug, "~> 1.18"},
+
{:jason, "~> 1.4"},
+
{:jose, "~> 1.11"},
+
{:bandit, "~> 1.0", only: [:dev, :test]}
+
]
+
end
+
+
defp package do
+
[
+
licenses: ["MIT"],
+
links: %{"GitHub" => @github, "Tangled" => @tangled}
+
]
+
end
+
+
defp docs do
+
[
+
extras: [
+
LICENSE: [title: "License"],
+
"README.md": [title: "Overview"],
+
"CHANGELOG.md": [title: "Changelog"]
+
],
+
main: "readme",
+
source_url: @github,
+
source_ref: "v#{@version}",
+
formatters: ["html"],
+
groups_for_modules: [
+
"Data types": [Atex.AtURI, Atex.DID, Atex.Handle, Atex.NSID, Atex.TID],
+
XRPC: ~r/^Atex\.XRPC/,
+
OAuth: [Atex.Config.OAuth, Atex.OAuth, Atex.OAuth.Plug],
+
Lexicons: ~r/^Atex\.Lexicon/,
+
Identity: ~r/^Atex\.IdentityResolver/
+
]
]
end
end
+27 -2
mix.lock
···
%{
+
"bandit": {:hex, :bandit, "1.8.0", "c2e93d7e3c5c794272fa4623124f827c6f24b643acc822be64c826f9447d92fb", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "8458ff4eed20ff2a2ea69d4854883a077c33ea42b51f6811b044ceee0fa15422"},
+
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
+
"cldr_utils": {:hex, :cldr_utils, "2.28.3", "d0ac5ed25913349dfaca8b7fe14722d588d8ccfa3e335b0510c7cc3f3c54d4e6", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:certifi, "~> 2.5", [hex: :certifi, repo: "hexpm", optional: true]}, {:decimal, "~> 1.9 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "40083cd9a5d187f12d675cfeeb39285f0d43e7b7f2143765161b72205d57ffb5"},
+
"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"},
+
"decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"},
"earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"},
-
"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"},
+
"ex_cldr": {:hex, :ex_cldr, "2.43.0", "8700031e30a03501cf65f7ba7c8287bb67339d03559f3108f3c54fe86d926b19", [:mix], [{:cldr_utils, "~> 2.28", [hex: :cldr_utils, repo: "hexpm", optional: false]}, {:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:gettext, "~> 0.19", [hex: :gettext, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: true]}], "hexpm", "1524eb01275b89473ee5f53fcc6169bae16e4a5267ef109229f37694799e0b20"},
+
"ex_doc": {:hex, :ex_doc, "0.38.3", "ddafe36b8e9fe101c093620879f6604f6254861a95133022101c08e75e6c759a", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "ecaa785456a67f63b4e7d7f200e8832fa108279e7eb73fd9928e7e66215a01f9"},
+
"file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"},
+
"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"},
+
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
+
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
+
"jose": {:hex, :jose, "1.11.10", "a903f5227417bd2a08c8a00a0cbcc458118be84480955e8d251297a425723f83", [:mix, :rebar3], [], "hexpm", "0d6cd36ff8ba174db29148fc112b5842186b68a90ce9fc2b3ec3afe76593e614"},
"makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"},
"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"},
"makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"},
+
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
+
"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"},
+
"multiformats_ex": {:hex, :multiformats_ex, "0.2.0", "5b0a3faa1a770dc671aa8a89b6323cc20b0ecf67dc93dcd21312151fbea6b4ee", [:mix], [{:varint, "~> 1.4", [hex: :varint, repo: "hexpm", optional: false]}], "hexpm", "aa406d9addb06dc197e0e92212992486af6599158d357680f29f2d11e08d0423"},
+
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
"nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
-
"typedstruct": {:hex, :typedstruct, "0.5.3", "d68ae424251a41b81a8d0c485328ab48edbd3858f3565bbdac21b43c056fc9b4", [:make, :mix], [], "hexpm", "b53b8186701417c0b2782bf02a2db5524f879b8488f91d1d83b97d84c2943432"},
+
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
+
"peri": {:hex, :peri, "0.6.1", "6a90ca728a27aef8fef37ce307444255d20364b0c8f8d39e52499d8d825cb514", [:mix], [{:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:stream_data, "~> 1.1", [hex: :stream_data, repo: "hexpm", optional: true]}], "hexpm", "e20ffc659967baf9c4f28799fe7302b656d6662a8b3db7646fdafd017e192743"},
+
"plug": {:hex, :plug, "1.18.1", "5067f26f7745b7e31bc3368bc1a2b818b9779faa959b49c934c17730efc911cf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "57a57db70df2b422b564437d2d33cf8d33cd16339c1edb190cd11b1a3a546cc2"},
+
"plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
+
"recase": {:hex, :recase, "0.9.0", "437982693fdfbec125f11c8868eb3b4d32e9aa6995d3a68ac8686f3e2bf5d8d1", [:mix], [], "hexpm", "efa7549ebd128988d1723037a6f6a61948055aec107db6288f1c52830cb6501c"},
+
"req": {:hex, :req, "0.5.15", "662020efb6ea60b9f0e0fac9be88cd7558b53fe51155a2d9899de594f9906ba9", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "a6513a35fad65467893ced9785457e91693352c70b58bbc045b47e5eb2ef0c53"},
+
"telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"},
+
"thousand_island": {:hex, :thousand_island, "1.4.1", "8df065e627407e281f7935da5ad0f3842d10eb721afa92e760b720d71e2e37aa", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "204a8640e5d2818589b87286ae66160978628d7edf6095181cbe0440765fb6c1"},
+
"typedstruct": {:hex, :typedstruct, "0.5.4", "d1d33d58460a74f413e9c26d55e66fd633abd8ac0fb12639add9a11a60a0462a", [:make, :mix], [], "hexpm", "ffaef36d5dbaebdbf4ed07f7fb2ebd1037b2c1f757db6fb8e7bcbbfabbe608d8"},
+
"varint": {:hex, :varint, "1.5.1", "17160c70d0428c3f8a7585e182468cac10bbf165c2360cf2328aaa39d3fb1795", [:mix], [], "hexpm", "24f3deb61e91cb988056de79d06f01161dd01be5e0acae61d8d936a552f1be73"},
+
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
}
+5
priv/templates/lexicon.eex
···
+
defmodule <%= Atex.NSID.to_atom(lexicon["id"], false) %> do
+
use Atex.Lexicon
+
+
deflexicon(<%= inspect(lexicon, limit: :infinity, pretty: true, printable_limit: :infinity) %>)
+
end
+4
test/tid_test.exs
···
+
defmodule TIDTest do
+
use ExUnit.Case, async: true
+
doctest Atex.TID
+
end