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

Compare changes

Choose any two refs to compare.

Changed files
+7692 -352
config
examples
lib
atex
atproto
com
atproto
admin
identity
label
lexicon
moderation
repo
server
sync
mix
priv
templates
test
atex
+7 -2
.formatter.exs
··· 1 # Used by "mix format" 2 [ 3 - inputs: ["{mix,.formatter,.credo}.exs", "{config,lib,test}/**/*.{ex,exs}"], 4 - import_deps: [:typedstruct, :peri], 5 export: [ 6 locals_without_parens: [deflexicon: 1] 7 ]
··· 1 # Used by "mix format" 2 [ 3 + inputs: 4 + Enum.flat_map( 5 + ["{mix,.formatter,.credo}.exs", "{config,examples,lib,test}/**/*.{ex,exs}"], 6 + &Path.wildcard(&1, match_dot: true) 7 + ) -- Path.wildcard("lib/atproto/**/*.ex"), 8 + import_deps: [:typedstruct, :peri, :plug], 9 + # excludes: ["lib/atproto/**/*.ex"], 10 export: [ 11 locals_without_parens: [deflexicon: 1] 12 ]
+6 -16
.gitignore
··· 1 - # The directory Mix will write compiled artifacts to. 2 /_build/ 3 - 4 - # If you run "mix test --cover", coverage assets end up here. 5 /cover/ 6 - 7 - # The directory Mix downloads your dependencies sources to. 8 /deps/ 9 - 10 - # Where third-party dependencies like ExDoc output generated docs. 11 /doc/ 12 - 13 - # If the VM crashes, it generates a dump, let's ignore it too. 14 erl_crash.dump 15 - 16 - # Also ignore archive artifacts (built via "mix archive.build"). 17 *.ez 18 - 19 - # Ignore package tarball (built via "mix hex.build"). 20 atex-*.tar 21 - 22 - # Temporary files, for example, from tests. 23 /tmp/ 24 25 .envrc 26 .direnv 27 .vscode/ 28 .elixir_ls 29 lexicons 30 - lib/atproto
··· 1 /_build/ 2 /cover/ 3 /deps/ 4 /doc/ 5 erl_crash.dump 6 *.ez 7 atex-*.tar 8 /tmp/ 9 + /priv/dets/ 10 11 .envrc 12 .direnv 13 .vscode/ 14 .elixir_ls 15 lexicons 16 + secrets 17 + .DS_Store 18 + CLAUDE.md 19 + tmp 20 + temp
+40
AGENTS.md
···
··· 1 + # Agent Guidelines for atex 2 + 3 + ## Commands 4 + 5 + - **Test**: `mix test` (all), `mix test test/path/to/file_test.exs` (single 6 + file), `mix test test/path/to/file_test.exs:42` (single test at line) 7 + - **Format**: `mix format` (auto-formats all code) 8 + - **Lint**: `mix credo` (static analysis, TODO checks disabled) 9 + - **Compile**: `mix compile` 10 + - **Docs**: `mix docs` 11 + 12 + ## Code Style 13 + 14 + - **Imports**: Use `alias` for modules (e.g., 15 + `alias Atex.Config.OAuth, as: Config`), import macros sparingly 16 + - **Formatting**: Elixir 1.18+, auto-formatted via `.formatter.exs` with 17 + `import_deps: [:typedstruct, :peri, :plug]` 18 + - **Naming**: snake_case for functions/variables, PascalCase for modules, 19 + descriptive names (e.g., `authorization_metadata`, not `auth_meta`) 20 + - **Types**: Use `@type` and `@spec` for all public functions; leverage 21 + TypedStruct for structs 22 + - **Moduledocs**: All public modules need `@moduledoc`, public functions need 23 + `@doc` with examples 24 + - When writing lists in documentation, use `-` as the list character. 25 + - **Error Handling**: Return `{:ok, result}` or `{:error, reason}` tuples; use 26 + pattern matching in case statements 27 + - **Pattern Matching**: Prefer pattern matching over conditionals; use guards 28 + when appropriate 29 + - **Macros**: Use `deflexicon` macro for lexicon definitions; use `defschema` 30 + (from Peri) for validation schemas 31 + - **Tests**: Async by default (`use ExUnit.Case, async: true`), use doctests 32 + where applicable 33 + - **Dependencies**: Core deps include Peri (validation), Req (HTTP), JOSE 34 + (JWT/OAuth), TypedStruct (structs) 35 + 36 + ## Important Notes 37 + 38 + - **DO NOT modify** `lib/atproto/**/` - autogenerated from official AT Protocol 39 + lexicons 40 + - **Update CHANGELOG.md** when adding features, changes, or fixes
+82 -1
CHANGELOG.md
··· 8 9 <!-- ## [Unreleased] --> 10 11 ## [0.4.0] - 2025-08-27 12 13 ### Added ··· 46 47 Initial release. 48 49 - [unreleased]: https://github.com/cometsh/atex/compare/v0.4.0...HEAD 50 [0.4.0]: https://github.com/cometsh/atex/releases/tag/v0.4.0 51 [0.3.0]: https://github.com/cometsh/atex/releases/tag/v0.3.0 52 [0.2.0]: https://github.com/cometsh/atex/releases/tag/v0.2.0
··· 8 9 <!-- ## [Unreleased] --> 10 11 + ## [0.7.0] - 2026-01-07 12 + 13 + ### Breaking Changes 14 + 15 + - `Atex.OAuth.Plug` now raises `Atex.OAuth.Error` exceptions instead of handling 16 + error situations internally. Applications should implement `Plug.ErrorHandler` 17 + to catch and gracefully handle them. 18 + - `Atex.OAuth.Plug` now saves only the user's DID in the session instead of the 19 + entire OAuth session object. Applications must use `Atex.OAuth.SessionStore` 20 + to manage OAuth sessions. 21 + - `Atex.XRPC.OAuthClient` has been overhauled to use `Atex.OAuth.SessionStore` 22 + for retrieving and managing OAuth sessions, making it easier to use with not 23 + needing to manually keep a Plug session in sync. 24 + 25 + ### Added 26 + 27 + - `Atex.OAuth.SessionStore` behaviour and `Atex.OAuth.Session` struct for 28 + managing OAuth sessions with pluggable storage backends. 29 + - `Atex.OAuth.SessionStore.ETS` - in-memory session store implementation. 30 + - `Atex.OAuth.SessionStore.DETS` - persistent disk-based session store 31 + implementation. 32 + - `Atex.OAuth.Plug` now requires a `:callback` option that is a MFA tuple 33 + (Module, Function, Args), denoting a callback function to be invoked by after 34 + a successful OAuth login. See [the OAuth example](./examples/oauth.ex) for a 35 + simple usage of this. 36 + - `Atex.OAuth.Permission` module for creating 37 + [AT Protocol permission](https://atproto.com/specs/permission) strings for 38 + OAuth. 39 + - `Atex.OAuth.Error` exception module for OAuth flow errors. Contains both a 40 + human-readable `message` string and a machine-readable `reason` atom for error 41 + handling. 42 + - `Atex.OAuth.Cache` module provides TTL caching for OAuth authorization server 43 + metadata with a 1-hour default TTL to reduce load on third-party PDSs. 44 + - `Atex.OAuth.get_authorization_server/2` and 45 + `Atex.OAuth.get_authorization_server_metadata/2` now support an optional 46 + `fresh` parameter to bypass the cache when needed. 47 + 48 + ### Changed 49 + 50 + - `mix atex.lexicons` now adds `@moduledoc false` to generated modules to stop 51 + them from automatically cluttering documentation. 52 + - `Atex.IdentityResolver.Cache.ETS` now uses ConCache instead of ETS directly, 53 + with a 1-hour TTL for cached identity information. 54 + 55 + ## [0.6.0] - 2025-11-25 56 + 57 + ### Breaking Changes 58 + 59 + - `deflexicon` now converts all def names to be in snake_case instead of the 60 + casing as written the lexicon. 61 + 62 + ### Added 63 + 64 + - `deflexicon` now emits structs for records, objects, queries, and procedures. 65 + - `Atex.XRPC.get/3` and `Atex.XRPC.post/3` now support having a lexicon struct 66 + as the second argument instead of the method's name, making it easier to have 67 + properly checked XRPC calls. 68 + - Add pre-transpiled modules for the core `com.atproto` lexicons. 69 + 70 + ## [0.5.0] - 2025-10-11 71 + 72 + ### Breaking Changes 73 + 74 + - Remove `Atex.HTTP` and associated modules as the abstraction caused a bit too 75 + much complexities for how early atex is. It may come back in the future as 76 + something more fleshed out once we're more stable. 77 + - Rename `Atex.XRPC.Client` to `Atex.XRPC.LoginClient` 78 + 79 + ### Added 80 + 81 + - `Atex.OAuth` module with utilites for handling some OAuth functionality. 82 + - `Atex.OAuth.Plug` module (if Plug is loaded) which provides a basic but 83 + complete OAuth flow, including storing the tokens in `Plug.Session`. 84 + - `Atex.XRPC.Client` behaviour for implementing custom client variants. 85 + - `Atex.XRPC` now supports using different client implementations. 86 + - `Atex.XRPC.OAuthClient` to make XRPC calls on the behalf of a user who has 87 + authenticated with ATProto OAuth. 88 + 89 ## [0.4.0] - 2025-08-27 90 91 ### Added ··· 124 125 Initial release. 126 127 + [unreleased]: https://github.com/cometsh/atex/compare/v0.7.0...HEAD 128 + [0.7.0]: https://github.com/cometsh/atex/releases/tag/v0.7.0 129 + [0.6.0]: https://github.com/cometsh/atex/releases/tag/v0.6.0 130 + [0.5.0]: https://github.com/cometsh/atex/releases/tag/v0.5.0 131 [0.4.0]: https://github.com/cometsh/atex/releases/tag/v0.4.0 132 [0.3.0]: https://github.com/cometsh/atex/releases/tag/v0.3.0 133 [0.2.0]: https://github.com/cometsh/atex/releases/tag/v0.2.0
+5 -3
README.md
··· 11 - [x] DID & handle resolution service with a cache 12 - [x] Macro for converting a Lexicon definition into a runtime-validation schema 13 - [x] Codegen to convert a directory of lexicons 14 - - [ ] Extended XRPC client with support for validated inputs/outputs 15 - - [ ] Oauth stuff 16 17 ## Installation 18 ··· 21 ```elixir 22 def deps do 23 [ 24 - {:atex, "~> 0.3"} 25 ] 26 end 27 ```
··· 11 - [x] DID & handle resolution service with a cache 12 - [x] Macro for converting a Lexicon definition into a runtime-validation schema 13 - [x] Codegen to convert a directory of lexicons 14 + - [x] Oauth stuff 15 + - [x] Extended XRPC client with support for validated inputs/outputs 16 + - [ ] Proper MST & CAR handling things 17 + - [ ] Pre-transpiled libraries for popular lexicons 18 19 ## Installation 20 ··· 23 ```elixir 24 def deps do 25 [ 26 + {:atex, "~> 0.7"} 27 ] 28 end 29 ```
+11
bump_builtin_lexicons.sh
···
··· 1 + #!/usr/bin/env bash 2 + 3 + set -euo pipefail 4 + 5 + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 6 + cd "$SCRIPT_DIR" 7 + 8 + mkdir -p ./tmp 9 + git clone --depth 1 --single-branch https://github.com/bluesky-social/atproto.git ./tmp/atproto 10 + mix atex.lexicons ./tmp/atproto/lexicons/com/atproto/**/*.json 11 + rm -rf ./tmp
+10
config/runtime.exs
···
··· 1 + import Config 2 + 3 + config :atex, Atex.OAuth, 4 + # base_url: "https://comet.sh/aaaa", 5 + base_url: "http://127.0.0.1:4000/oauth", 6 + is_localhost: true, 7 + scopes: ~w(transition:generic), 8 + private_key: 9 + "MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgyIpxhuDm0i3mPkrk6UdX4Sd9Jsv6YtAmSTza+A2nArShRANCAAQLF1GLueOBZOVnKWfrcnoDOO9NSRqH2utmfGMz+Rce18MDB7Z6CwFWjEq2UFYNBI4MI5cMI0+m+UYAmj4OZm+m", 10 + key_id: "awooga"
+113
examples/oauth.ex
···
··· 1 + defmodule ExampleOAuthPlug do 2 + require Logger 3 + use Plug.Router 4 + use Plug.ErrorHandler 5 + alias Atex.OAuth 6 + alias Atex.XRPC 7 + 8 + plug :put_secret_key_base 9 + 10 + plug Plug.Session, 11 + store: :cookie, 12 + key: "atex-oauth", 13 + signing_salt: "signing-salt" 14 + 15 + plug :match 16 + plug :dispatch 17 + 18 + forward "/oauth", to: Atex.OAuth.Plug, init_opts: [callback: {__MODULE__, :oauth_callback, []}] 19 + 20 + def oauth_callback(conn) do 21 + IO.inspect(conn, label: "callback from oauth!") 22 + 23 + conn 24 + |> put_resp_header("Location", "/whoami") 25 + |> resp(307, "") 26 + |> send_resp() 27 + end 28 + 29 + get "/whoami" do 30 + conn = fetch_session(conn) 31 + 32 + case XRPC.OAuthClient.from_conn(conn) do 33 + {:ok, client} -> 34 + send_resp(conn, 200, "hello #{client.did}") 35 + 36 + :error -> 37 + send_resp(conn, 401, "Unauthorized") 38 + end 39 + end 40 + 41 + get "/create-post" do 42 + conn = fetch_session(conn) 43 + 44 + with {:ok, client} <- XRPC.OAuthClient.from_conn(conn), 45 + {:ok, response, client} <- 46 + XRPC.post(client, %Com.Atproto.Repo.CreateRecord{ 47 + input: %Com.Atproto.Repo.CreateRecord.Input{ 48 + repo: client.did, 49 + collection: "app.bsky.feed.post", 50 + rkey: Atex.TID.now() |> to_string(), 51 + record: %{ 52 + "$type": "app.bsky.feed.post", 53 + text: "Hello world from atex!", 54 + createdAt: DateTime.to_iso8601(DateTime.utc_now()) 55 + } 56 + } 57 + }) do 58 + IO.inspect(response, label: "output") 59 + 60 + send_resp(conn, 200, response.body.uri) 61 + else 62 + :error -> 63 + send_resp(conn, 401, "Unauthorized") 64 + 65 + {:error, :reauth} -> 66 + send_resp(conn, 401, "session expired but still in your cookie") 67 + 68 + err -> 69 + IO.inspect(err, label: "xrpc failed") 70 + send_resp(conn, 500, "xrpc failed") 71 + end 72 + end 73 + 74 + match _ do 75 + send_resp(conn, 404, "oops") 76 + end 77 + 78 + def put_secret_key_base(conn, _) do 79 + put_in( 80 + conn.secret_key_base, 81 + # Don't use this in production 82 + "5ef1078e1617463a3eb3feb9b152e76587a75a6809e0485a125b6bb7ae468f086680771f700d77ff61dfdc8d8ee8a5c7848024a41cf5ad4b6eb3115f74ce6e46" 83 + ) 84 + end 85 + 86 + # Error handler for OAuth exceptions 87 + @impl Plug.ErrorHandler 88 + def handle_errors(conn, %{kind: :error, reason: %Atex.OAuth.Error{} = error, stack: _stack}) do 89 + status = 90 + case error.reason do 91 + reason 92 + when reason in [ 93 + :missing_handle, 94 + :invalid_handle, 95 + :invalid_callback_request, 96 + :issuer_mismatch 97 + ] -> 98 + 400 99 + 100 + _ -> 101 + 500 102 + end 103 + 104 + conn 105 + |> put_resp_content_type("text/plain") 106 + |> send_resp(status, error.message) 107 + end 108 + 109 + # Fallback for other errors 110 + def handle_errors(conn, %{kind: _kind, reason: _reason, stack: _stack}) do 111 + send_resp(conn, conn.status, "Something went wrong") 112 + end 113 + end
+3 -3
flake.lock
··· 2 "nodes": { 3 "nixpkgs": { 4 "locked": { 5 - "lastModified": 1755615617, 6 - "narHash": "sha256-HMwfAJBdrr8wXAkbGhtcby1zGFvs+StOp19xNsbqdOg=", 7 "owner": "nixos", 8 "repo": "nixpkgs", 9 - "rev": "20075955deac2583bb12f07151c2df830ef346b4", 10 "type": "github" 11 }, 12 "original": {
··· 2 "nodes": { 3 "nixpkgs": { 4 "locked": { 5 + "lastModified": 1767379071, 6 + "narHash": "sha256-EgE0pxsrW9jp9YFMkHL9JMXxcqi/OoumPJYwf+Okucw=", 7 "owner": "nixos", 8 "repo": "nixpkgs", 9 + "rev": "fb7944c166a3b630f177938e478f0378e64ce108", 10 "type": "github" 11 }, 12 "original": {
+7 -1
lib/atex/application.ex
··· 4 use Application 5 6 def start(_type, _args) do 7 - children = [Atex.IdentityResolver.Cache] 8 Supervisor.start_link(children, strategy: :one_for_one) 9 end 10 end
··· 4 use Application 5 6 def start(_type, _args) do 7 + children = [ 8 + Atex.IdentityResolver.Cache, 9 + Atex.OAuth.Cache, 10 + Atex.OAuth.SessionStore, 11 + {Mutex, name: Atex.SessionMutex} 12 + ] 13 + 14 Supervisor.start_link(children, strategy: :one_for_one) 15 end 16 end
+86
lib/atex/config/oauth.ex
···
··· 1 + defmodule Atex.Config.OAuth do 2 + @moduledoc """ 3 + Configuration management for `Atex.OAuth`. 4 + 5 + Contains all the logic for fetching configuration needed for the OAuth 6 + module, as well as deriving useful values from them. 7 + 8 + ## Configuration 9 + 10 + The following structure is expected in your application config: 11 + 12 + config :atex, Atex.OAuth, 13 + base_url: "https://example.com/oauth", # Your application's base URL, including the path `Atex.OAuth` is mounted on. 14 + private_key: "base64-encoded-private-key", # ES256 private key 15 + key_id: "your-key-id", # Key identifier for JWTs 16 + scopes: ["transition:generic", "transition:email"], # Optional additional scopes 17 + extra_redirect_uris: ["https://alternative.com/callback"], # Optional additional redirect URIs 18 + is_localhost: false # Set to true for local development 19 + """ 20 + 21 + @doc """ 22 + Returns the configured public base URL for OAuth routes. 23 + """ 24 + @spec base_url() :: String.t() 25 + def base_url, do: Application.fetch_env!(:atex, Atex.OAuth)[:base_url] 26 + 27 + @doc """ 28 + Returns the configured private key as a `JOSE.JWK`. 29 + """ 30 + @spec get_key() :: JOSE.JWK.t() 31 + def get_key() do 32 + private_key = 33 + Application.fetch_env!(:atex, Atex.OAuth)[:private_key] 34 + |> Base.decode64!() 35 + |> JOSE.JWK.from_der() 36 + 37 + key_id = Application.fetch_env!(:atex, Atex.OAuth)[:key_id] 38 + 39 + %{private_key | fields: %{"kid" => key_id}} 40 + end 41 + 42 + @doc """ 43 + Returns the client ID based on configuration. 44 + 45 + If `is_localhost` is set, it'll be a string handling the "http://localhost" 46 + special case, with the redirect URI and scopes configured, otherwise it is a 47 + string pointing to the location of the `client-metadata.json` route. 48 + """ 49 + @spec client_id() :: String.t() 50 + def client_id() do 51 + is_localhost = Keyword.get(Application.get_env(:atex, Atex.OAuth, []), :is_localhost, false) 52 + 53 + if is_localhost do 54 + query = 55 + %{redirect_uri: redirect_uri(), scope: scopes()} 56 + |> URI.encode_query() 57 + 58 + "http://localhost?#{query}" 59 + else 60 + "#{base_url()}/client-metadata.json" 61 + end 62 + end 63 + 64 + @doc """ 65 + Returns the configured redirect URI. 66 + """ 67 + @spec redirect_uri() :: String.t() 68 + def redirect_uri(), do: "#{base_url()}/callback" 69 + 70 + @doc """ 71 + Returns the configured scopes joined as a space-separated string. 72 + """ 73 + @spec scopes() :: String.t() 74 + def scopes() do 75 + config_scopes = Keyword.get(Application.get_env(:atex, Atex.OAuth, []), :scopes, []) 76 + Enum.join(["atproto" | config_scopes], " ") 77 + end 78 + 79 + @doc """ 80 + Returns the configured extra redirect URIs. 81 + """ 82 + @spec extra_redirect_uris() :: [String.t()] 83 + def extra_redirect_uris() do 84 + Keyword.get(Application.get_env(:atex, Atex.OAuth, []), :extra_redirect_uris, []) 85 + end 86 + end
-38
lib/atex/http/adapter/req.ex
··· 1 - defmodule Atex.HTTP.Adapter.Req do 2 - @moduledoc """ 3 - `Req` adapter for atex. 4 - """ 5 - 6 - @behaviour Atex.HTTP.Adapter 7 - 8 - @impl true 9 - def get(url, opts) do 10 - Req.get(url, opts) |> adapt() 11 - end 12 - 13 - @impl true 14 - def post(url, opts) do 15 - Req.post(url, opts) |> adapt() 16 - end 17 - 18 - @spec adapt({:ok, Req.Response.t()} | {:error, any()}) :: Atex.HTTP.Adapter.result() 19 - defp adapt({:ok, %Req.Response{status: status} = res}) when status < 400 do 20 - {:ok, to_response(res)} 21 - end 22 - 23 - defp adapt({:ok, %Req.Response{} = res}) do 24 - {:error, to_response(res)} 25 - end 26 - 27 - defp adapt({:error, exception}) do 28 - {:error, exception} 29 - end 30 - 31 - defp to_response(%Req.Response{} = res) do 32 - %Atex.HTTP.Response{ 33 - body: res.body, 34 - status: res.status, 35 - __raw__: res 36 - } 37 - end 38 - end
···
-13
lib/atex/http/adapter.ex
··· 1 - defmodule Atex.HTTP.Adapter do 2 - @moduledoc """ 3 - Behaviour for defining a HTTP client adapter to be used within atex. 4 - """ 5 - alias Atex.HTTP.Response 6 - 7 - @type success() :: {:ok, Response.t()} 8 - @type error() :: {:error, Response.t() | term()} 9 - @type result() :: success() | error() 10 - 11 - @callback get(url :: String.t(), opts :: keyword()) :: result() 12 - @callback post(url :: String.t(), opts :: keyword()) :: result() 13 - end
···
-13
lib/atex/http/response.ex
··· 1 - defmodule Atex.HTTP.Response do 2 - @moduledoc """ 3 - A generic response struct to be returned by an `Atex.HTTP.Adapter`. 4 - """ 5 - 6 - use TypedStruct 7 - 8 - typedstruct enforce: true do 9 - field :status, integer() 10 - field :body, any() 11 - field :__raw__, any() 12 - end 13 - end
···
-6
lib/atex/http.ex
··· 1 - defmodule Atex.HTTP do 2 - @adapter Application.compile_env(:atex, :adapter, Atex.HTTP.Adapter.Req) 3 - 4 - defdelegate get(url, opts), to: @adapter 5 - defdelegate post(url, opts), to: @adapter 6 - end
···
+35 -22
lib/atex/identity_resolver/cache/ets.ex
··· 1 defmodule Atex.IdentityResolver.Cache.ETS do 2 alias Atex.IdentityResolver.Identity 3 @behaviour Atex.IdentityResolver.Cache 4 use Supervisor 5 6 - @table :atex_identities 7 8 def start_link(opts) do 9 - Supervisor.start_link(__MODULE__, opts) 10 end 11 12 @impl Supervisor 13 def init(_opts) do 14 - :ets.new(@table, [:set, :public, :named_table]) 15 - Supervisor.init([], strategy: :one_for_one) 16 end 17 18 @impl Atex.IdentityResolver.Cache 19 @spec insert(Identity.t()) :: Identity.t() 20 def insert(identity) do 21 - # TODO: benchmark lookups vs match performance, is it better to use a "composite" key or two inserts? 22 - :ets.insert(@table, {{identity.did, identity.handle}, identity}) 23 identity 24 end 25 26 @impl Atex.IdentityResolver.Cache 27 @spec get(String.t()) :: {:ok, Identity.t()} | {:error, atom()} 28 def get(identifier) do 29 - lookup(identifier) 30 end 31 32 @impl Atex.IdentityResolver.Cache 33 @spec delete(String.t()) :: :noop | Identity.t() 34 def delete(identifier) do 35 - case lookup(identifier) do 36 {:ok, identity} -> 37 - :ets.delete(@table, {identity.did, identity.handle}) 38 identity 39 40 _ -> 41 :noop 42 - end 43 - end 44 - 45 - defp lookup(identifier) do 46 - case :ets.match(@table, {{identifier, :_}, :"$1"}) do 47 - [] -> 48 - case :ets.match(@table, {{:_, identifier}, :"$1"}) do 49 - [] -> {:error, :not_found} 50 - [[identity]] -> {:ok, identity} 51 - end 52 - 53 - [[identity]] -> 54 - {:ok, identity} 55 end 56 end 57 end
··· 1 defmodule Atex.IdentityResolver.Cache.ETS do 2 + @moduledoc """ 3 + ConCache-based implementation for Identity Resolver caching. 4 + 5 + Stores identity information (DID and handle mappings) with a 1-hour TTL. 6 + Uses two separate cache entries per identity to allow lookups by either DID or handle. 7 + """ 8 + 9 alias Atex.IdentityResolver.Identity 10 @behaviour Atex.IdentityResolver.Cache 11 use Supervisor 12 13 + @cache :atex_identities_cache 14 + @ttl_ms :timer.hours(1) 15 16 def start_link(opts) do 17 + Supervisor.start_link(__MODULE__, opts, name: __MODULE__) 18 end 19 20 @impl Supervisor 21 def init(_opts) do 22 + children = [ 23 + {ConCache, 24 + [ 25 + name: @cache, 26 + ttl_check_interval: :timer.minutes(5), 27 + global_ttl: @ttl_ms 28 + ]} 29 + ] 30 + 31 + Supervisor.init(children, strategy: :one_for_one) 32 end 33 34 @impl Atex.IdentityResolver.Cache 35 @spec insert(Identity.t()) :: Identity.t() 36 def insert(identity) do 37 + ConCache.put(@cache, {:did, identity.did}, identity) 38 + ConCache.put(@cache, {:handle, identity.handle}, identity) 39 identity 40 end 41 42 @impl Atex.IdentityResolver.Cache 43 @spec get(String.t()) :: {:ok, Identity.t()} | {:error, atom()} 44 def get(identifier) do 45 + case ConCache.get(@cache, {:did, identifier}) do 46 + nil -> 47 + case ConCache.get(@cache, {:handle, identifier}) do 48 + nil -> {:error, :not_found} 49 + identity -> {:ok, identity} 50 + end 51 + 52 + identity -> 53 + {:ok, identity} 54 + end 55 end 56 57 @impl Atex.IdentityResolver.Cache 58 @spec delete(String.t()) :: :noop | Identity.t() 59 def delete(identifier) do 60 + case get(identifier) do 61 {:ok, identity} -> 62 + ConCache.delete(@cache, {:did, identity.did}) 63 + ConCache.delete(@cache, {:handle, identity.handle}) 64 identity 65 66 _ -> 67 :noop 68 end 69 end 70 end
+2 -2
lib/atex/identity_resolver/did.ex
··· 14 @spec resolve_plc(String.t()) :: resolution_result() 15 defp resolve_plc("did:plc:" <> _id = did) do 16 with {:ok, resp} when resp.status in 200..299 <- 17 - Atex.HTTP.get("https://plc.directory/#{did}", []), 18 {:ok, body} <- decode_body(resp.body), 19 {:ok, document} <- DIDDocument.from_json(body), 20 :ok <- DIDDocument.validate_for_atproto(document, did) do ··· 29 @spec resolve_web(String.t()) :: resolution_result() 30 defp resolve_web("did:web:" <> domain = did) do 31 with {:ok, resp} when resp.status in 200..299 <- 32 - Atex.HTTP.get("https://#{domain}/.well-known/did.json", []), 33 {:ok, body} <- decode_body(resp.body), 34 {:ok, document} <- DIDDocument.from_json(body), 35 :ok <- DIDDocument.validate_for_atproto(document, did) do
··· 14 @spec resolve_plc(String.t()) :: resolution_result() 15 defp resolve_plc("did:plc:" <> _id = did) do 16 with {:ok, resp} when resp.status in 200..299 <- 17 + Req.get("https://plc.directory/#{did}"), 18 {:ok, body} <- decode_body(resp.body), 19 {:ok, document} <- DIDDocument.from_json(body), 20 :ok <- DIDDocument.validate_for_atproto(document, did) do ··· 29 @spec resolve_web(String.t()) :: resolution_result() 30 defp resolve_web("did:web:" <> domain = did) do 31 with {:ok, resp} when resp.status in 200..299 <- 32 + Req.get("https://#{domain}/.well-known/did.json"), 33 {:ok, body} <- decode_body(resp.body), 34 {:ok, document} <- DIDDocument.from_json(body), 35 :ok <- DIDDocument.validate_for_atproto(document, did) do
+13
lib/atex/identity_resolver/did_document.ex
··· 125 end) 126 end 127 128 defp valid_pds_endpoint?(endpoint) do 129 case URI.new(endpoint) do 130 {:ok, uri} ->
··· 125 end) 126 end 127 128 + @spec get_pds_endpoint(t()) :: String.t() | nil 129 + def get_pds_endpoint(%__MODULE__{} = doc) do 130 + doc.service 131 + |> Enum.find(fn 132 + %{id: "#atproto_pds", type: "AtprotoPersonalDataServer"} -> true 133 + _ -> false 134 + end) 135 + |> case do 136 + nil -> nil 137 + pds -> pds.service_endpoint 138 + end 139 + end 140 + 141 defp valid_pds_endpoint?(endpoint) do 142 case URI.new(endpoint) do 143 {:ok, uri} ->
+1 -1
lib/atex/identity_resolver/handle.ex
··· 55 56 @spec resolve_via_http(String.t()) :: {:ok, String.t()} | :error 57 defp resolve_via_http(handle) do 58 - case Atex.HTTP.get("https://#{handle}/.well-known/atproto-did", []) do 59 {:ok, %{body: "did:" <> _ = did}} -> {:ok, did} 60 _ -> :error 61 end
··· 55 56 @spec resolve_via_http(String.t()) :: {:ok, String.t()} | :error 57 defp resolve_via_http(handle) do 58 + case Req.get("https://#{handle}/.well-known/atproto-did") do 59 {:ok, %{body: "did:" <> _ = did}} -> {:ok, did} 60 _ -> :error 61 end
+2 -2
lib/atex/lexicon/validators/array.ex
··· 4 @option_keys [:min_length, :max_length] 5 6 # Needs type input 7 - @spec validate(Peri.schema_def(), term(), list(option())) :: Peri.validation_result() 8 - def validate(inner_type, value, options) when is_list(value) do 9 # TODO: validate inner_type with Peri to make sure it's correct? 10 11 options
··· 4 @option_keys [:min_length, :max_length] 5 6 # Needs type input 7 + @spec validate(term(), Peri.schema_def(), list(option())) :: Peri.validation_result() 8 + def validate(value, inner_type, options) when is_list(value) do 9 # TODO: validate inner_type with Peri to make sure it's correct? 10 11 options
+13 -44
lib/atex/lexicon/validators/string.ex
··· 1 defmodule Atex.Lexicon.Validators.String do 2 alias Atex.Lexicon.Validators 3 4 - @type format() :: 5 - :at_identifier 6 - | :at_uri 7 - | :cid 8 - | :datetime 9 - | :did 10 - | :handle 11 - | :nsid 12 - | :tid 13 - | :record_key 14 - | :uri 15 - | :language 16 - 17 @type option() :: 18 - {:format, format()} 19 | {:min_length, non_neg_integer()} 20 | {:max_length, non_neg_integer()} 21 | {:min_graphemes, non_neg_integer()} ··· 31 32 @record_key_re ~r"^[a-zA-Z0-9.-_:~]$" 33 34 - # TODO: probably should go into a different module, one with general lexicon -> validator gen conversions 35 - @spec format_to_atom(String.t()) :: format() 36 - def format_to_atom(format) do 37 - case format do 38 - "at-identifier" -> :at_identifier 39 - "at-uri" -> :at_uri 40 - "cid" -> :cid 41 - "datetime" -> :datetime 42 - "did" -> :did 43 - "handle" -> :handle 44 - "nsid" -> :nsid 45 - "tid" -> :tid 46 - "record-key" -> :record_key 47 - "uri" -> :uri 48 - "language" -> :language 49 - _ -> raise "Unknown lexicon string format `#{format}`" 50 - end 51 - end 52 - 53 @spec validate(term(), list(option())) :: Peri.validation_result() 54 def validate(value, options) when is_binary(value) do 55 options ··· 74 75 defp validate_option(_value, {option, nil}) when option in @option_keys, do: :ok 76 77 - defp validate_option(value, {:format, :at_identifier}), 78 do: 79 Validators.boolean_validate( 80 Atex.DID.match?(value) or Atex.Handle.match?(value), 81 "should be a valid DID or handle" 82 ) 83 84 - defp validate_option(value, {:format, :at_uri}), 85 do: Validators.boolean_validate(Atex.AtURI.match?(value), "should be a valid at:// URI") 86 87 - defp validate_option(value, {:format, :cid}) do 88 # TODO: is there a regex provided by the lexicon docs/somewhere? 89 try do 90 Multiformats.CID.decode(value) 91 rescue 92 _ -> {:error, "should be a valid CID", []} 93 end 94 end 95 96 - defp validate_option(value, {:format, :datetime}) do 97 # NaiveDateTime is used over DateTime because the result isn't actually 98 # being used, so we don't need to include a calendar library just for this. 99 case NaiveDateTime.from_iso8601(value) do ··· 102 end 103 end 104 105 - defp validate_option(value, {:format, :did}), 106 do: Validators.boolean_validate(Atex.DID.match?(value), "should be a valid DID") 107 108 - defp validate_option(value, {:format, :handle}), 109 do: Validators.boolean_validate(Atex.Handle.match?(value), "should be a valid handle") 110 111 - defp validate_option(value, {:format, :nsid}), 112 do: Validators.boolean_validate(Atex.NSID.match?(value), "should be a valid NSID") 113 114 - defp validate_option(value, {:format, :tid}), 115 do: Validators.boolean_validate(Atex.TID.match?(value), "should be a valid TID") 116 117 - defp validate_option(value, {:format, :record_key}), 118 do: 119 Validators.boolean_validate( 120 Regex.match?(@record_key_re, value), 121 "should be a valid record key" 122 ) 123 124 - defp validate_option(value, {:format, :uri}) do 125 case URI.new(value) do 126 {:ok, _} -> :ok 127 {:error, _} -> {:error, "should be a valid URI", []} 128 end 129 end 130 131 - defp validate_option(value, {:format, :language}) do 132 case Cldr.LanguageTag.parse(value) do 133 {:ok, _} -> :ok 134 {:error, _} -> {:error, "should be a valid BCP 47 language tag", []}
··· 1 defmodule Atex.Lexicon.Validators.String do 2 alias Atex.Lexicon.Validators 3 4 @type option() :: 5 + {:format, String.t()} 6 | {:min_length, non_neg_integer()} 7 | {:max_length, non_neg_integer()} 8 | {:min_graphemes, non_neg_integer()} ··· 18 19 @record_key_re ~r"^[a-zA-Z0-9.-_:~]$" 20 21 @spec validate(term(), list(option())) :: Peri.validation_result() 22 def validate(value, options) when is_binary(value) do 23 options ··· 42 43 defp validate_option(_value, {option, nil}) when option in @option_keys, do: :ok 44 45 + defp validate_option(value, {:format, "at-identifier"}), 46 do: 47 Validators.boolean_validate( 48 Atex.DID.match?(value) or Atex.Handle.match?(value), 49 "should be a valid DID or handle" 50 ) 51 52 + defp validate_option(value, {:format, "at-uri"}), 53 do: Validators.boolean_validate(Atex.AtURI.match?(value), "should be a valid at:// URI") 54 55 + defp validate_option(value, {:format, "cid"}) do 56 # TODO: is there a regex provided by the lexicon docs/somewhere? 57 try do 58 Multiformats.CID.decode(value) 59 + :ok 60 rescue 61 _ -> {:error, "should be a valid CID", []} 62 end 63 end 64 65 + defp validate_option(value, {:format, "datetime"}) do 66 # NaiveDateTime is used over DateTime because the result isn't actually 67 # being used, so we don't need to include a calendar library just for this. 68 case NaiveDateTime.from_iso8601(value) do ··· 71 end 72 end 73 74 + defp validate_option(value, {:format, "did"}), 75 do: Validators.boolean_validate(Atex.DID.match?(value), "should be a valid DID") 76 77 + defp validate_option(value, {:format, "handle"}), 78 do: Validators.boolean_validate(Atex.Handle.match?(value), "should be a valid handle") 79 80 + defp validate_option(value, {:format, "nsid"}), 81 do: Validators.boolean_validate(Atex.NSID.match?(value), "should be a valid NSID") 82 83 + defp validate_option(value, {:format, "tid"}), 84 do: Validators.boolean_validate(Atex.TID.match?(value), "should be a valid TID") 85 86 + defp validate_option(value, {:format, "record-key"}), 87 do: 88 Validators.boolean_validate( 89 Regex.match?(@record_key_re, value), 90 "should be a valid record key" 91 ) 92 93 + defp validate_option(value, {:format, "uri"}) do 94 case URI.new(value) do 95 {:ok, _} -> :ok 96 {:error, _} -> {:error, "should be a valid URI", []} 97 end 98 end 99 100 + defp validate_option(value, {:format, "language"}) do 101 case Cldr.LanguageTag.parse(value) do 102 {:ok, _} -> :ok 103 {:error, _} -> {:error, "should be a valid BCP 47 language tag", []}
+5
lib/atex/lexicon/validators.ex
··· 81 } 82 end 83 84 @spec boolean_validate(boolean(), String.t(), keyword() | map()) :: 85 Peri.validation_result() 86 def boolean_validate(success?, error_message, context \\ []) do
··· 81 } 82 end 83 84 + @spec lazy_ref(module(), atom()) :: Peri.schema() 85 + def lazy_ref(module, schema_name) do 86 + {:custom, {module, schema_name, []}} 87 + end 88 + 89 @spec boolean_validate(boolean(), String.t(), keyword() | map()) :: 90 Peri.validation_result() 91 def boolean_validate(success?, error_message, context \\ []) do
+307 -59
lib/atex/lexicon.ex
··· 1 defmodule Atex.Lexicon do 2 - @moduledoc """ 3 - Provide `deflexicon` macro for defining a module with types and schemas from an entire lexicon definition. 4 - 5 - Should it also define structs, with functions to convert from input case to snake case? 6 - """ 7 - 8 alias Atex.Lexicon.Validators 9 10 defmacro __using__(_opts) do ··· 15 end 16 end 17 18 defmacro deflexicon(lexicon) do 19 # Better way to get the real map, without having to eval? (custom function to compose one from quoted?) 20 lexicon = ··· 27 defs = 28 lexicon.defs 29 |> Enum.flat_map(fn {def_name, def} -> def_to_schema(lexicon.id, def_name, def) end) 30 - |> Enum.map(fn {schema_key, quoted_schema, quoted_type} -> 31 identity_type = 32 - if schema_key === :main do 33 quote do 34 @type t() :: unquote(quoted_type) 35 end 36 end 37 38 quote do 39 - @type unquote(schema_key)() :: unquote(quoted_type) 40 unquote(identity_type) 41 42 - defschema unquote(schema_key), unquote(quoted_schema) 43 end 44 end) 45 46 quote do 47 - def id, do: unquote(Atex.NSID.to_atom(lexicon.id)) 48 49 unquote_splicing(defs) 50 end 51 end 52 53 @spec def_to_schema(nsid :: String.t(), def_name :: String.t(), lexicon_def :: map()) :: 54 - list({key :: atom(), quoted_schema :: term(), quoted_type :: term()}) 55 56 defp def_to_schema(nsid, def_name, %{type: "record", record: record}) do 57 # TODO: record rkey format validator 58 def_to_schema(nsid, def_name, record) 59 end 60 61 - # TODO: need to spit out an extra 'branded' type with `$type` field, for use in union refs. 62 defp def_to_schema( 63 nsid, 64 def_name, ··· 70 required = Map.get(def, :required, []) 71 nullable = Map.get(def, :nullable, []) 72 73 - properties 74 - |> Enum.map(fn {key, field} -> 75 - {quoted_schema, quoted_type} = field_to_schema(field, nsid) 76 - is_nullable = key in nullable 77 - is_required = key in required 78 79 - quoted_schema = 80 - quoted_schema 81 - |> then( 82 - &if is_nullable, do: quote(do: {:either, {{:literal, nil}, unquote(&1)}}), else: &1 83 - ) 84 - |> then(&if is_required, do: quote(do: {:required, unquote(&1)}), else: &1) 85 - |> then(&{key, &1}) 86 87 - key_type = if is_required, do: :required, else: :optional 88 89 - quoted_type = 90 - quoted_type 91 - |> then( 92 - &if is_nullable do 93 - {:|, [], [&1, nil]} 94 - else 95 - &1 96 end 97 - ) 98 - |> then(&{{key_type, [], [key]}, &1}) 99 100 - {quoted_schema, quoted_type} 101 - end) 102 - |> Enum.reduce({[], []}, fn {quoted_schema, quoted_type}, {schemas, types} -> 103 - {[quoted_schema | schemas], [quoted_type | types]} 104 - end) 105 - |> then(fn {quoted_schemas, quoted_types} -> 106 - [{atomise(def_name), {:%{}, [], quoted_schemas}, {:%{}, [], quoted_types}}] 107 - end) 108 end 109 110 # TODO: validating errors? ··· 127 schema 128 end 129 130 - [params, output] 131 |> Enum.reject(&is_nil/1) 132 end 133 ··· 157 schema 158 end 159 160 - [params, output, input] 161 |> Enum.reject(&is_nil/1) 162 end 163 ··· 231 :minGraphemes 232 ]) 233 |> Enum.map(fn {k, v} -> {Recase.to_snake(k), v} end) 234 - |> then(&{:custom, {Validators.String, :validate, [&1]}}) 235 |> maybe_default(field) 236 end 237 |> then( ··· 262 field 263 |> Map.take([:maximum, :minimum]) 264 |> Keyword.new() 265 - |> then(&{:custom, {Validators.Integer, [&1]}}) 266 |> maybe_default(field) 267 end 268 |> then( ··· 284 |> Enum.map(fn {k, v} -> {Recase.to_snake(k), v} end) 285 |> then(&Validators.array(inner_schema, &1)) 286 |> then(&Macro.escape/1) 287 # Can't unquote the inner_schema beforehand as that would risk evaluating `get_schema`s which don't exist yet. 288 # There's probably a better way to do this lol. 289 |> then(fn {:custom, {:{}, c, [Validators.Array, :validate, [quoted_inner_schema | args]]}} -> ··· 341 |> Atex.NSID.expand_possible_fragment_shorthand(ref) 342 |> Atex.NSID.to_atom_with_fragment() 343 344 - {quote do 345 - unquote(nsid).get_schema(unquote(fragment)) 346 - end, 347 - quote do 348 - unquote(nsid).unquote(fragment)() 349 - end} 350 end 351 352 defp field_to_schema(%{type: "union", refs: refs}, nsid) do ··· 362 |> Atex.NSID.expand_possible_fragment_shorthand(ref) 363 |> Atex.NSID.to_atom_with_fragment() 364 365 - {quote do 366 - unquote(nsid).get_schema(unquote(fragment)) 367 - end, 368 - quote do 369 - unquote(nsid).unquote(fragment)() 370 - end} 371 end) 372 |> Enum.reduce({[], []}, fn {quoted_schema, quoted_type}, {schemas, types} -> 373 {[quoted_schema | schemas], [quoted_type | types]}
··· 1 defmodule Atex.Lexicon do 2 alias Atex.Lexicon.Validators 3 4 defmacro __using__(_opts) do ··· 9 end 10 end 11 12 + @doc """ 13 + Defines a lexicon module from a JSON lexicon definition. 14 + 15 + The `deflexicon` macro processes the provided lexicon map (typically loaded 16 + from a JSON file) and generates: 17 + 18 + - **Typespecs** for each definition, exposing a `t/0` type for the main 19 + definition and named types for any additional definitions. 20 + - **`Peri` schemas** via `defschema/2` for runtime validation of data. 21 + - **Structs** for object and record definitions, with `@enforce_keys` ensuring 22 + required fields are present. 23 + - For **queries** and **procedures**, it creates structs for `params`, 24 + `input`, and `output` when those sections exist in the lexicon. It also 25 + generates a topโ€‘level struct that aggregates `params` and `input` (when 26 + applicable); this struct is used by the XRPC client to locate the 27 + appropriate output struct. 28 + 29 + If a procedure doesn't have a schema for a JSON body specified as it's input, 30 + the top-level struct will instead have a `raw_input` field, allowing for 31 + miscellaneous bodies such as a binary blob. 32 + 33 + The generated structs also implement the `JSON.Encoder` and `Jason.Encoder` 34 + protocols (the latter currently present for compatibility), as well as a 35 + `from_json` function which is used to validate an input map - e.g. from a JSON 36 + HTTP response - and turn it into a struct. 37 + 38 + ## Example 39 + 40 + deflexicon(%{ 41 + "lexicon" => 1, 42 + "id" => "com.ovyerus.testing", 43 + "defs" => %{ 44 + "main" => %{ 45 + "type" => "record", 46 + "key" => "tid", 47 + "record" => %{ 48 + "type" => "object", 49 + "required" => ["foobar"], 50 + "properties" => %{ "foobar" => %{ "type" => "string" } } 51 + } 52 + } 53 + } 54 + }) 55 + 56 + The macro expands to following code (truncated for brevity): 57 + 58 + @type main() :: %{required(:foobar) => String.t(), optional(:"$type") => String.t()} 59 + @type t() :: %{required(:foobar) => String.t(), optional(:"$type") => String.t()} 60 + 61 + defschema(:main, %{ 62 + foobar: {:required, {:custom, {Atex.Lexicon.Validators.String, :validate, [[]]}}}, 63 + "$type": {{:literal, "com.ovyerus.testing"}, {:default, "com.ovyerus.testing"}} 64 + }) 65 + 66 + @enforce_keys [:foobar] 67 + defstruct foobar: nil, "$type": "com.ovyerus.testing" 68 + 69 + def from_json(json) do 70 + case apply(Com.Ovyerus.Testing, :main, [json]) do 71 + {:ok, map} -> {:ok, struct(__MODULE__, map)} 72 + err -> err 73 + end 74 + end 75 + 76 + The generated module can be used directly with `Atex.XRPC` functions, allowing 77 + typeโ€‘safe construction of requests and automatic decoding of responses. 78 + """ 79 defmacro deflexicon(lexicon) do 80 # Better way to get the real map, without having to eval? (custom function to compose one from quoted?) 81 lexicon = ··· 88 defs = 89 lexicon.defs 90 |> Enum.flat_map(fn {def_name, def} -> def_to_schema(lexicon.id, def_name, def) end) 91 + |> Enum.map(fn 92 + {schema_key, quoted_schema, quoted_type} -> {schema_key, quoted_schema, quoted_type, nil} 93 + x -> x 94 + end) 95 + |> Enum.map(fn {schema_key, quoted_schema, quoted_type, quoted_struct} -> 96 identity_type = 97 + if schema_key == :main do 98 quote do 99 @type t() :: unquote(quoted_type) 100 end 101 end 102 103 + struct_def = 104 + if schema_key == :main do 105 + quoted_struct 106 + else 107 + nested_module_name = 108 + schema_key 109 + |> Recase.to_pascal() 110 + |> atomise() 111 + 112 + quote do 113 + defmodule unquote({:__aliases__, [alias: false], [nested_module_name]}) do 114 + unquote(quoted_struct) 115 + end 116 + end 117 + end 118 + 119 quote do 120 + @type unquote(Recase.to_snake(schema_key))() :: unquote(quoted_type) 121 unquote(identity_type) 122 123 + defschema unquote(Recase.to_snake(schema_key)), unquote(quoted_schema) 124 + 125 + unquote(struct_def) 126 end 127 end) 128 129 quote do 130 + def id, do: unquote(lexicon.id) 131 132 unquote_splicing(defs) 133 end 134 end 135 + 136 + # - [ ] `t()` type should be the struct in it. (add to non-main structs too?) 137 138 @spec def_to_schema(nsid :: String.t(), def_name :: String.t(), lexicon_def :: map()) :: 139 + list( 140 + { 141 + key :: atom(), 142 + quoted_schema :: term(), 143 + quoted_type :: term() 144 + } 145 + | { 146 + key :: atom(), 147 + quoted_schema :: term(), 148 + quoted_type :: term(), 149 + quoted_struct :: term() 150 + } 151 + ) 152 153 defp def_to_schema(nsid, def_name, %{type: "record", record: record}) do 154 # TODO: record rkey format validator 155 + type_name = Atex.NSID.canonical_name(nsid, to_string(def_name)) 156 + 157 + record = 158 + put_in(record, [:properties, :"$type"], %{ 159 + type: "string", 160 + const: type_name, 161 + default: type_name 162 + }) 163 + 164 def_to_schema(nsid, def_name, record) 165 end 166 167 + # TODO: add struct to types 168 defp def_to_schema( 169 nsid, 170 def_name, ··· 176 required = Map.get(def, :required, []) 177 nullable = Map.get(def, :nullable, []) 178 179 + {quoted_schemas, quoted_types} = 180 + properties 181 + |> Enum.map(fn {key, field} -> 182 + {quoted_schema, quoted_type} = field_to_schema(field, nsid) 183 + string_key = to_string(key) 184 + is_nullable = string_key in nullable 185 + is_required = string_key in required 186 + 187 + quoted_schema = 188 + quoted_schema 189 + |> then( 190 + &if is_nullable, do: quote(do: {:either, {{:literal, nil}, unquote(&1)}}), else: &1 191 + ) 192 + |> then(&if is_required, do: quote(do: {:required, unquote(&1)}), else: &1) 193 + |> then(&{key, &1}) 194 195 + key_type = if is_required, do: :required, else: :optional 196 197 + quoted_type = 198 + quoted_type 199 + |> then( 200 + &if is_nullable do 201 + {:|, [], [&1, nil]} 202 + else 203 + &1 204 + end 205 + ) 206 + |> then(&{{key_type, [], [key]}, &1}) 207 208 + {quoted_schema, quoted_type} 209 + end) 210 + |> Enum.reduce({[], []}, fn {quoted_schema, quoted_type}, {schemas, types} -> 211 + {[quoted_schema | schemas], [quoted_type | types]} 212 + end) 213 + 214 + struct_keys = 215 + properties 216 + |> Enum.filter(fn {key, _} -> key !== :"$type" end) 217 + |> Enum.map(fn 218 + {key, %{default: default}} -> {key, default} 219 + {key, _field} -> {key, nil} 220 + end) 221 + |> then(&(&1 ++ [{:"$type", if(def_name == :main, do: nsid, else: "#{nsid}##{def_name}")}])) 222 + 223 + enforced_keys = 224 + properties |> Map.keys() |> Enum.filter(&(to_string(&1) in required && &1 != :"$type")) 225 + 226 + optional_if_nil_keys = 227 + properties 228 + |> Map.keys() 229 + |> Enum.filter(fn key -> 230 + key = to_string(key) 231 + # TODO: what if it is nullable but not required? 232 + key not in required && key not in nullable && key != "$type" 233 + end) 234 + 235 + schema_module = Atex.NSID.to_atom(nsid) 236 + 237 + quoted_struct = 238 + quote do 239 + @enforce_keys unquote(enforced_keys) 240 + defstruct unquote(struct_keys) 241 + 242 + def from_json(json) do 243 + case apply(unquote(schema_module), unquote(atomise(def_name)), [json]) do 244 + {:ok, map} -> {:ok, struct(__MODULE__, map)} 245 + err -> err 246 end 247 + end 248 249 + defimpl JSON.Encoder do 250 + @optional_if_nil_keys unquote(optional_if_nil_keys) 251 + 252 + def encode(value, encoder) do 253 + value 254 + |> Map.from_struct() 255 + |> Enum.reject(fn {k, v} -> k in @optional_if_nil_keys && v == nil end) 256 + |> Enum.into(%{}) 257 + |> Jason.Encoder.encode(encoder) 258 + end 259 + end 260 + 261 + defimpl Jason.Encoder do 262 + @optional_if_nil_keys unquote(optional_if_nil_keys) 263 + 264 + def encode(value, options) do 265 + value 266 + |> Map.from_struct() 267 + |> Enum.reject(fn {k, v} -> k in @optional_if_nil_keys && v == nil end) 268 + |> Enum.into(%{}) 269 + |> Jason.Encode.map(options) 270 + end 271 + end 272 + end 273 + 274 + [{atomise(def_name), {:%{}, [], quoted_schemas}, {:%{}, [], quoted_types}, quoted_struct}] 275 end 276 277 # TODO: validating errors? ··· 294 schema 295 end 296 297 + # Root struct containing `params` 298 + main = 299 + if params do 300 + { 301 + :main, 302 + nil, 303 + quote do 304 + %__MODULE__{params: params()} 305 + end, 306 + quote do 307 + @enforce_keys [:params] 308 + defstruct params: nil 309 + end 310 + } 311 + else 312 + { 313 + :main, 314 + nil, 315 + quote do 316 + %__MODULE__{} 317 + end, 318 + quote do 319 + defstruct [] 320 + end 321 + } 322 + end 323 + 324 + [main, params, output] 325 |> Enum.reject(&is_nil/1) 326 end 327 ··· 351 schema 352 end 353 354 + # Root struct containing `input`, `raw_input`, and `params` 355 + main = 356 + { 357 + :main, 358 + nil, 359 + cond do 360 + params && input -> 361 + quote do 362 + %__MODULE__{input: input(), params: params()} 363 + end 364 + 365 + input -> 366 + quote do 367 + %__MODULE__{input: input()} 368 + end 369 + 370 + params -> 371 + quote do 372 + %__MODULE__{raw_input: any(), params: params()} 373 + end 374 + 375 + true -> 376 + quote do 377 + %__MODULE__{raw_input: any()} 378 + end 379 + end, 380 + cond do 381 + params && input -> 382 + quote do 383 + defstruct input: nil, params: nil 384 + end 385 + 386 + input -> 387 + quote do 388 + defstruct input: nil 389 + end 390 + 391 + params -> 392 + quote do 393 + defstruct raw_input: nil, params: nil 394 + end 395 + 396 + true -> 397 + quote do 398 + defstruct raw_input: nil 399 + end 400 + end 401 + } 402 + 403 + [main, params, output, input] 404 |> Enum.reject(&is_nil/1) 405 end 406 ··· 474 :minGraphemes 475 ]) 476 |> Enum.map(fn {k, v} -> {Recase.to_snake(k), v} end) 477 + |> Validators.string() 478 |> maybe_default(field) 479 end 480 |> then( ··· 505 field 506 |> Map.take([:maximum, :minimum]) 507 |> Keyword.new() 508 + |> Validators.integer() 509 |> maybe_default(field) 510 end 511 |> then( ··· 527 |> Enum.map(fn {k, v} -> {Recase.to_snake(k), v} end) 528 |> then(&Validators.array(inner_schema, &1)) 529 |> then(&Macro.escape/1) 530 + # TODO: we should be able to unquote this now... 531 # Can't unquote the inner_schema beforehand as that would risk evaluating `get_schema`s which don't exist yet. 532 # There's probably a better way to do this lol. 533 |> then(fn {:custom, {:{}, c, [Validators.Array, :validate, [quoted_inner_schema | args]]}} -> ··· 585 |> Atex.NSID.expand_possible_fragment_shorthand(ref) 586 |> Atex.NSID.to_atom_with_fragment() 587 588 + fragment = Recase.to_snake(fragment) 589 + 590 + { 591 + Macro.escape(Validators.lazy_ref(nsid, fragment)), 592 + quote do 593 + unquote(nsid).unquote(fragment)() 594 + end 595 + } 596 end 597 598 defp field_to_schema(%{type: "union", refs: refs}, nsid) do ··· 608 |> Atex.NSID.expand_possible_fragment_shorthand(ref) 609 |> Atex.NSID.to_atom_with_fragment() 610 611 + fragment = Recase.to_snake(fragment) 612 + 613 + { 614 + Macro.escape(Validators.lazy_ref(nsid, fragment)), 615 + quote do 616 + unquote(nsid).unquote(fragment)() 617 + end 618 + } 619 end) 620 |> Enum.reduce({[], []}, fn {quoted_schema, quoted_type}, {schemas, types} -> 621 {[quoted_schema | schemas], [quoted_type | types]}
+9
lib/atex/nsid.ex
··· 45 possible_fragment 46 end 47 end 48 end
··· 45 possible_fragment 46 end 47 end 48 + 49 + @spec canonical_name(String.t(), String.t()) :: String.t() 50 + def canonical_name(nsid, fragment) do 51 + if fragment == "main" do 52 + nsid 53 + else 54 + "#{nsid}##{fragment}" 55 + end 56 + end 57 end
+127
lib/atex/oauth/cache.ex
···
··· 1 + defmodule Atex.OAuth.Cache do 2 + @moduledoc """ 3 + TTL cache for OAuth authorization server information. 4 + 5 + This module manages two separate ConCache instances: 6 + - Authorization server cache (stores PDS -> authz server mappings) 7 + - Authorization metadata cache (stores authz server -> metadata mappings) 8 + 9 + Both caches use a 1-hour TTL to reduce load on third-party PDSs. 10 + """ 11 + 12 + use Supervisor 13 + 14 + @authz_server_cache :oauth_authz_server_cache 15 + @authz_metadata_cache :oauth_authz_metadata_cache 16 + @ttl_ms :timer.hours(1) 17 + 18 + @doc """ 19 + Starts the OAuth cache supervisor. 20 + """ 21 + def start_link(opts) do 22 + Supervisor.start_link(__MODULE__, opts, name: __MODULE__) 23 + end 24 + 25 + @impl Supervisor 26 + def init(_opts) do 27 + children = [ 28 + Supervisor.child_spec( 29 + {ConCache, 30 + [ 31 + name: @authz_server_cache, 32 + ttl_check_interval: :timer.minutes(5), 33 + global_ttl: @ttl_ms 34 + ]}, 35 + id: :authz_server_cache 36 + ), 37 + Supervisor.child_spec( 38 + {ConCache, 39 + [ 40 + name: @authz_metadata_cache, 41 + ttl_check_interval: :timer.seconds(30), 42 + global_ttl: @ttl_ms 43 + ]}, 44 + id: :authz_metadata_cache 45 + ) 46 + ] 47 + 48 + Supervisor.init(children, strategy: :one_for_one) 49 + end 50 + 51 + @doc """ 52 + Get authorization server from cache. 53 + 54 + ## Parameters 55 + 56 + - `pds_host` - Base URL of the PDS (e.g., "https://bsky.social") 57 + 58 + ## Returns 59 + 60 + - `{:ok, authorization_server}` - Successfully retrieved from cache 61 + - `{:error, :not_found}` - Not present in cache 62 + """ 63 + @spec get_authorization_server(String.t()) :: {:ok, String.t()} | {:error, :not_found} 64 + def get_authorization_server(pds_host) do 65 + case ConCache.get(@authz_server_cache, pds_host) do 66 + nil -> {:error, :not_found} 67 + value -> {:ok, value} 68 + end 69 + end 70 + 71 + @doc """ 72 + Store authorization server in cache. 73 + 74 + ## Parameters 75 + 76 + - `pds_host` - Base URL of the PDS 77 + - `authorization_server` - Authorization server URL to cache 78 + 79 + ## Returns 80 + 81 + - `:ok` 82 + """ 83 + @spec set_authorization_server(String.t(), String.t()) :: :ok 84 + def set_authorization_server(pds_host, authorization_server) do 85 + ConCache.put(@authz_server_cache, pds_host, authorization_server) 86 + :ok 87 + end 88 + 89 + @doc """ 90 + Get authorization server metadata from cache. 91 + 92 + ## Parameters 93 + 94 + - `issuer` - Authorization server issuer URL 95 + 96 + ## Returns 97 + 98 + - `{:ok, metadata}` - Successfully retrieved from cache 99 + - `{:error, :not_found}` - Not present in cache 100 + """ 101 + @spec get_authorization_server_metadata(String.t()) :: 102 + {:ok, Atex.OAuth.authorization_metadata()} | {:error, :not_found} 103 + def get_authorization_server_metadata(issuer) do 104 + case ConCache.get(@authz_metadata_cache, issuer) do 105 + nil -> {:error, :not_found} 106 + value -> {:ok, value} 107 + end 108 + end 109 + 110 + @doc """ 111 + Store authorization server metadata in cache. 112 + 113 + ## Parameters 114 + 115 + - `issuer` - Authorization server issuer URL 116 + - `metadata` - Authorization server metadata to cache 117 + 118 + ## Returns 119 + 120 + - `:ok` 121 + """ 122 + @spec set_authorization_server_metadata(String.t(), Atex.OAuth.authorization_metadata()) :: :ok 123 + def set_authorization_server_metadata(issuer, metadata) do 124 + ConCache.put(@authz_metadata_cache, issuer, metadata) 125 + :ok 126 + end 127 + end
+25
lib/atex/oauth/error.ex
···
··· 1 + defmodule Atex.OAuth.Error do 2 + @moduledoc """ 3 + Exception raised by `Atex.OAuth.Plug` when errors occurred. When using the 4 + Plug, you should set up a `Plug.ErrorHandler` to gracefully catch these and 5 + give messages to the end user. 6 + 7 + This extesion has two fields: a human-readable `message` string, and an atom 8 + `reason` for each specific error. 9 + 10 + ## Reasons 11 + 12 + - `:missing_handle` - The handle query parameter was not provided 13 + - `:invalid_handle` - The provided handle could not be resolved 14 + - `:authorization_url_failed` - Failed to create the authorization URL 15 + - `:invalid_callback_request` - Missing or invalid state/code in callback 16 + - `:authorization_server_metadata_failed` - Could not fetch authorization 17 + server metadata 18 + - `:token_validation_failed` - Failed to validate the authorization code or 19 + token 20 + - `:issuer_mismatch` - OAuth issuer does not match PDS authorization server 21 + - `:session_store_failed` - OAuth succeeded but failed to store the session 22 + """ 23 + 24 + defexception [:message, :reason] 25 + end
+809
lib/atex/oauth/permission.ex
···
··· 1 + defmodule Atex.OAuth.Permission do 2 + use TypedStruct 3 + import Kernel, except: [to_string: 1] 4 + 5 + @type t_tuple() :: { 6 + resource :: String.t(), 7 + positional :: String.t() | nil, 8 + parameters :: list({String.t(), String.t()}) 9 + } 10 + 11 + @typep as_string() :: {:as_string, boolean()} 12 + @type account_attr() :: :email | :repo 13 + @type account_action() :: :read | :manage 14 + @type account_opt() :: 15 + {:attr, account_attr()} | {:action, account_action()} | as_string() 16 + 17 + @type repo_opt() :: 18 + {:create, boolean()} | {:update, boolean()} | {:delete, boolean()} | as_string() 19 + 20 + @type rpc_opt() :: {:aud, String.t()} | {:inherit_aud, boolean()} | as_string() 21 + 22 + @type include_opt() :: {:aud, String.t()} | as_string() 23 + 24 + typedstruct enforce: true do 25 + field :resource, String.t() 26 + field :positional, String.t() | nil 27 + # like a Keyword list but with a string instead of an atom 28 + field :parameters, list({String.t(), String.t()}), enforce: false, default: [] 29 + end 30 + 31 + @doc """ 32 + Creates a new permission struct from a permission scope string. 33 + 34 + Parses an AT Protocol OAuth permission scope string and returns a structured 35 + representation. Permission strings follow the format 36 + `resource:positional?key=value&key2=value2` 37 + 38 + The positional parameter is resource-specific and may be omitted in some cases 39 + (e.g., collection for `repo`, lxm for `rpc`, attr for `account`/`identity`, 40 + accept for `blob`). 41 + 42 + See the [AT Protocol 43 + documentation](https://atproto.com/specs/permission#scope-string-syntax) for 44 + the full syntax and rules for permission scope strings. 45 + 46 + ## Parameters 47 + - `string` - A permission scope string (e.g., "repo:app.example.profile") 48 + 49 + Returns `{:ok, permission}` if a valid scope string was given, otherwise it 50 + will return `{:error, reason}`. 51 + 52 + ## Examples 53 + 54 + # Simple with just a positional 55 + iex> Atex.OAuth.Permission.new("repo:app.example.profile") 56 + {:ok, %Atex.OAuth.Permission{ 57 + resource: "repo", 58 + positional: "app.example.profile", 59 + parameters: [] 60 + }} 61 + 62 + # With parameters 63 + iex> Atex.OAuth.Permission.new("repo?collection=app.example.profile&collection=app.example.post") 64 + {:ok, %Atex.OAuth.Permission{ 65 + resource: "repo", 66 + positional: nil, 67 + parameters: [ 68 + {"collection", "app.example.profile"}, 69 + {"collection", "app.example.post"} 70 + ] 71 + }} 72 + 73 + # Positional with parameters 74 + iex> Atex.OAuth.Permission.new("rpc:app.example.moderation.createReport?aud=*") 75 + {:ok, %Atex.OAuth.Permission{ 76 + resource: "rpc", 77 + positional: "app.example.moderation.createReport", 78 + parameters: [{"aud", "*"}] 79 + }} 80 + 81 + iex> Atex.OAuth.Permission.new("blob:*/*") 82 + {:ok, %Atex.OAuth.Permission{ 83 + resource: "blob", 84 + positional: "*/*", 85 + parameters: [] 86 + }} 87 + 88 + # Invalid: resource without positional or parameters 89 + iex> Atex.OAuth.Permission.new("resource") 90 + {:error, :missing_positional_or_parameters} 91 + 92 + """ 93 + @spec new(String.t()) :: {:ok, t()} | {:error, reason :: atom()} 94 + def new(string) do 95 + case parse(string) do 96 + {:ok, {resource, positional, parameters}} -> 97 + {:ok, %__MODULE__{resource: resource, positional: positional, parameters: parameters}} 98 + 99 + err -> 100 + err 101 + end 102 + end 103 + 104 + @doc """ 105 + Parses an AT Protocol permission scope string into its components. 106 + 107 + Returns a tuple containing the resource name, optional positional parameter, 108 + and a list of key-value parameter pairs. This is a lower-level function 109 + compared to `new/1`, returning the raw components instead of a struct. 110 + 111 + ## Parameters 112 + - `string` - A permission scope string following the format 113 + `resource:positional?key=value&key2=value2` 114 + 115 + Returns `{:ok, {resource, positional, parameters}}` if a valid scope string 116 + was given, otherwise it will return `{:error, reason}`. 117 + 118 + ## Examples 119 + 120 + # Simple with just a positional 121 + iex> Atex.OAuth.Permission.parse("repo:app.example.profile") 122 + {:ok, {"repo", "app.example.profile", []}} 123 + 124 + # With parameters 125 + iex> Atex.OAuth.Permission.parse("repo?collection=app.example.profile&collection=app.example.post") 126 + {:ok, { 127 + "repo", 128 + nil, 129 + [ 130 + {"collection", "app.example.profile"}, 131 + {"collection", "app.example.post"} 132 + ] 133 + }} 134 + 135 + # Positional with parameters 136 + iex> Atex.OAuth.Permission.parse("rpc:app.example.moderation.createReport?aud=*") 137 + {:ok, {"rpc", "app.example.moderation.createReport", [{"aud", "*"}]}} 138 + 139 + iex> Atex.OAuth.Permission.parse("blob:*/*") 140 + {:ok, {"blob", "*/*", []}} 141 + 142 + # Invalid: resource without positional or parameters 143 + iex> Atex.OAuth.Permission.parse("resource") 144 + {:error, :missing_positional_or_parameters} 145 + 146 + """ 147 + @spec parse(String.t()) :: 148 + {:ok, t_tuple()} 149 + | {:error, reason :: atom()} 150 + def parse(string) do 151 + case String.split(string, "?", parts: 2) do 152 + [resource_part] -> 153 + parse_resource_and_positional(resource_part) 154 + 155 + # Empty parameter string is treated as absent 156 + [resource_part, ""] -> 157 + parse_resource_and_positional(resource_part) 158 + 159 + [resource_part, params_part] -> 160 + params_part 161 + |> parse_parameters() 162 + |> then(&parse_resource_and_positional(resource_part, &1)) 163 + end 164 + end 165 + 166 + @spec parse_resource_and_positional(String.t(), list({String.t(), String.t()})) :: 167 + {:ok, t_tuple()} | {:error, reason :: atom()} 168 + defp parse_resource_and_positional(resource_part, parameters \\ []) do 169 + case String.split(resource_part, ":", parts: 2) do 170 + [resource_name, positional] -> 171 + {:ok, {resource_name, positional, parameters}} 172 + 173 + [resource_name] -> 174 + if parameters == [] do 175 + {:error, :missing_positional_or_parameters} 176 + else 177 + {:ok, {resource_name, nil, parameters}} 178 + end 179 + end 180 + end 181 + 182 + @spec parse_parameters(String.t()) :: list({String.t(), String.t()}) 183 + defp parse_parameters(params_string) do 184 + params_string 185 + |> String.split("&") 186 + |> Enum.map(fn param -> 187 + case String.split(param, "=", parts: 2) do 188 + [key, value] -> {key, URI.decode(value)} 189 + [key] -> {key, ""} 190 + end 191 + end) 192 + end 193 + 194 + @doc """ 195 + Converts a permission struct back into its scope string representation. 196 + 197 + This is the inverse operation of `new/1`, converting a structured permission 198 + back into the AT Protocol OAuth scope string format. The resulting string 199 + can be used directly as an OAuth scope parameter. 200 + 201 + Values in `parameters` are automatically URL-encoded as needed (e.g., `#` becomes `%23`). 202 + 203 + ## Parameters 204 + - `struct` - An `%Atex.OAuth.Permission{}` struct 205 + 206 + Returns a permission scope string. 207 + 208 + ## Examples 209 + 210 + # Simple with just a positional 211 + iex> perm = %Atex.OAuth.Permission{ 212 + ...> resource: "repo", 213 + ...> positional: "app.example.profile", 214 + ...> parameters: [] 215 + ...> } 216 + iex> Atex.OAuth.Permission.to_string(perm) 217 + "repo:app.example.profile" 218 + 219 + # With parameters 220 + iex> perm = %Atex.OAuth.Permission{ 221 + ...> resource: "repo", 222 + ...> positional: nil, 223 + ...> parameters: [ 224 + ...> {"collection", "app.example.profile"}, 225 + ...> {"collection", "app.example.post"} 226 + ...> ] 227 + ...> } 228 + iex> Atex.OAuth.Permission.to_string(perm) 229 + "repo?collection=app.example.profile&collection=app.example.post" 230 + 231 + # Positional with parameters 232 + iex> perm = %Atex.OAuth.Permission{ 233 + ...> resource: "rpc", 234 + ...> positional: "app.example.moderation.createReport", 235 + ...> parameters: [{"aud", "*"}] 236 + ...> } 237 + iex> Atex.OAuth.Permission.to_string(perm) 238 + "rpc:app.example.moderation.createReport?aud=*" 239 + 240 + iex> perm = %Atex.OAuth.Permission{ 241 + ...> resource: "blob", 242 + ...> positional: "*/*", 243 + ...> parameters: [] 244 + ...> } 245 + iex> Atex.OAuth.Permission.to_string(perm) 246 + "blob:*/*" 247 + 248 + # Works via String.Chars protocol 249 + iex> perm = %Atex.OAuth.Permission{ 250 + ...> resource: "account", 251 + ...> positional: "email", 252 + ...> parameters: [] 253 + ...> } 254 + iex> to_string(perm) 255 + "account:email" 256 + 257 + """ 258 + @spec to_string(t()) :: String.t() 259 + def to_string(%__MODULE__{} = struct) do 260 + positional_part = if struct.positional, do: ":#{struct.positional}", else: "" 261 + parameters_part = stringify_parameters(struct.parameters) 262 + 263 + struct.resource <> positional_part <> parameters_part 264 + end 265 + 266 + @spec stringify_parameters(list({String.t(), String.t()})) :: String.t() 267 + defp stringify_parameters([]), do: "" 268 + 269 + defp stringify_parameters(params) do 270 + params 271 + |> Enum.map(fn {key, value} -> "#{key}=#{encode_param_value(value)}" end) 272 + |> Enum.join("&") 273 + |> then(&"?#{&1}") 274 + end 275 + 276 + # Encode parameter values for OAuth scope strings 277 + # Preserves unreserved characters (A-Z, a-z, 0-9, -, ., _, ~) and common scope characters (*, :, /) 278 + # Encodes reserved characters like # as %23 279 + @spec encode_param_value(String.t()) :: String.t() 280 + defp encode_param_value(value) do 281 + URI.encode(value, fn char -> 282 + URI.char_unreserved?(char) or char in [?*, ?:, ?/] 283 + end) 284 + end 285 + 286 + @doc """ 287 + Creates an account permission for controlling PDS account hosting details. 288 + 289 + Controls access to private account information such as email address and 290 + repository import capabilities. These permissions cannot be included in 291 + permission sets and must be requested directly by client apps. 292 + 293 + See the [AT Protocol documentation](https://atproto.com/specs/permission#account) 294 + for more information. 295 + 296 + ## Options 297 + - `:attr` (required) - A component of account configuration. Must be `:email` 298 + or `:repo`. 299 + - `:action` (optional) - Degree of control. Can be `:read` or `:manage`. 300 + Defaults to `:read`. 301 + - `:as_string` (optional) - If `true` (default), returns a scope string, 302 + otherwise returns a Permission struct. 303 + 304 + If `:as_string` is true a scope string is returned, otherwise the underlying 305 + Permission struct is returned. 306 + 307 + ## Examples 308 + 309 + # Read account email (default action, as string) 310 + iex> Atex.OAuth.Permission.account(attr: :email) 311 + "account:email" 312 + 313 + # Read account email (as struct) 314 + iex> Atex.OAuth.Permission.account(attr: :email, as_string: false) 315 + %Atex.OAuth.Permission{ 316 + resource: "account", 317 + positional: "email", 318 + parameters: [] 319 + } 320 + 321 + # Read account email (explicit action) 322 + iex> Atex.OAuth.Permission.account(attr: :email, action: :read) 323 + "account:email?action=read" 324 + 325 + # Manage account email 326 + iex> Atex.OAuth.Permission.account(attr: :email, action: :manage) 327 + "account:email?action=manage" 328 + 329 + # Import repo 330 + iex> Atex.OAuth.Permission.account(attr: :repo, action: :manage) 331 + "account:repo?action=manage" 332 + 333 + """ 334 + @spec account(list(account_opt())) :: t() | String.t() 335 + def account(opts \\ []) do 336 + opts = Keyword.validate!(opts, attr: nil, action: nil, as_string: true) 337 + attr = Keyword.get(opts, :attr) 338 + action = Keyword.get(opts, :action) 339 + as_string = Keyword.get(opts, :as_string) 340 + 341 + cond do 342 + is_nil(attr) -> 343 + raise ArgumentError, "option `:attr` must be provided." 344 + 345 + attr not in [:email, :repo] -> 346 + raise ArgumentError, "option `:attr` must be `:email` or `:repo`." 347 + 348 + action not in [nil, :read, :manage] -> 349 + raise ArgumentError, "option `:action` must be `:read`, `:manage`, or `nil`." 350 + 351 + true -> 352 + struct = %__MODULE__{ 353 + resource: "account", 354 + positional: Atom.to_string(attr), 355 + parameters: if(!is_nil(action), do: [{"action", Atom.to_string(action)}], else: []) 356 + } 357 + 358 + if as_string, do: to_string(struct), else: struct 359 + end 360 + end 361 + 362 + @doc """ 363 + Creates a blob permission for uploading media files to PDS. 364 + 365 + Controls the ability to upload blobs (media files) to the PDS. Permissions can 366 + be restricted by MIME type patterns. 367 + 368 + See the [AT Protocol documentation](https://atproto.com/specs/permission#blob) 369 + for more information. 370 + 371 + <!-- TODO: When permission sets are supported, add the note from the docs about this not being allowed in permisison sets. --> 372 + 373 + ## Parameters 374 + - `accept` - A single MIME type string or list of MIME type strings/patterns. 375 + Supports glob patterns like `"*/*"` or `"video/*"`. 376 + - `opts` - Keyword list of options. 377 + 378 + ## Options 379 + - `:as_string` (optional) - If `true` (default), returns a scope string, otherwise 380 + returns a Permission struct. 381 + 382 + If `:as_string` is true a scope string is returned, otherwise the underlying 383 + Permission struct is returned. 384 + 385 + ## Examples 386 + 387 + # Upload any type of blob 388 + iex> Atex.OAuth.Permission.blob("*/*") 389 + "blob:*/*" 390 + 391 + # Only images 392 + iex> Atex.OAuth.Permission.blob("image/*", as_string: false) 393 + %Atex.OAuth.Permission{ 394 + resource: "blob", 395 + positional: "image/*", 396 + parameters: [] 397 + } 398 + 399 + # Multiple mimetypes 400 + iex> Atex.OAuth.Permission.blob(["video/*", "text/html"]) 401 + "blob?accept=video/*&accept=text/html" 402 + 403 + # Multiple more specific mimetypes 404 + iex> Atex.OAuth.Permission.blob(["image/png", "image/jpeg"], as_string: false) 405 + %Atex.OAuth.Permission{ 406 + resource: "blob", 407 + positional: nil, 408 + parameters: [{"accept", "image/png"}, {"accept", "image/jpeg"}] 409 + } 410 + 411 + """ 412 + # TODO: should probably validate that these at least look like mimetypes (~r"^.+/.+$") 413 + @spec blob(String.t() | list(String.t()), list(as_string())) :: t() | String.t() 414 + def blob(accept, opts \\ []) 415 + 416 + def blob(accept, opts) when is_binary(accept) do 417 + opts = Keyword.validate!(opts, as_string: true) 418 + as_string = Keyword.get(opts, :as_string) 419 + struct = %__MODULE__{resource: "blob", positional: accept} 420 + if as_string, do: to_string(struct), else: struct 421 + end 422 + 423 + def blob(accept, opts) when is_list(accept) do 424 + opts = Keyword.validate!(opts, as_string: true) 425 + as_string = Keyword.get(opts, :as_string) 426 + 427 + struct = %__MODULE__{ 428 + resource: "blob", 429 + positional: nil, 430 + parameters: Enum.map(accept, &{"accept", &1}) 431 + } 432 + 433 + if as_string, do: to_string(struct), else: struct 434 + end 435 + 436 + @doc """ 437 + Creates an identity permission for controlling network identity. 438 + 439 + Controls access to the account's DID document and handle. Note that the PDS 440 + might not be able to facilitate identity changes if it does not have control 441 + over the DID document (e.g., when using `did:web`). 442 + 443 + <!-- TODO: same thing about not allowed in permission sets. --> 444 + 445 + See the [AT Protocol 446 + documentation](https://atproto.com/specs/permission#identity) for more 447 + information. 448 + 449 + ## Parameters 450 + - `attr` - An aspect or component of identity. Must be `:handle` or `:*` 451 + (wildcard). 452 + - `opts` - Keyword list of options. 453 + 454 + ## Options 455 + - `:as_string` (optional) - If `true` (default), returns a scope string, 456 + otherwise returns a Permission struct. 457 + 458 + If `:as_string` is true a scope string is returned, otherwise the underlying 459 + Permission struct is returned. 460 + 461 + ## Examples 462 + 463 + # Update account handle (as string) 464 + iex> Atex.OAuth.Permission.identity(:handle) 465 + "identity:handle" 466 + 467 + # Full identity control (as struct) 468 + iex> Atex.OAuth.Permission.identity(:*, as_string: false) 469 + %Atex.OAuth.Permission{ 470 + resource: "identity", 471 + positional: "*", 472 + parameters: [] 473 + } 474 + 475 + """ 476 + @spec identity(:handle | :*, list(as_string())) :: t() | String.t() 477 + def identity(attr, opts \\ []) when attr in [:handle, :*] do 478 + opts = Keyword.validate!(opts, as_string: true) 479 + as_string = Keyword.get(opts, :as_string) 480 + 481 + struct = %__MODULE__{ 482 + resource: "identity", 483 + positional: Atom.to_string(attr) 484 + } 485 + 486 + if as_string, do: to_string(struct), else: struct 487 + end 488 + 489 + @doc """ 490 + Creates a repo permission for write access to records in the account's public 491 + repository. 492 + 493 + Controls write access to specific record types (collections) with optional 494 + restrictions on the types of operations allowed (create, update, delete). 495 + 496 + When no options are provided, all operations are permitted. When any action 497 + option is explicitly set, only the actions set to `true` are enabled. This 498 + allows for precise control over permissions. 499 + 500 + See the [AT Protocol documentation](https://atproto.com/specs/permission#repo) 501 + for more information. 502 + 503 + ## Parameters 504 + - `collection_or_collections` - A single collection NSID string or list of 505 + collection NSIDs. Use `"*"` for wildcard access to all record types (not 506 + allowed in permission sets). 507 + - `options` - Keyword list to restrict operations. If omitted, all operations 508 + are allowed. If any action is specified, only explicitly enabled actions are 509 + permitted. 510 + 511 + ## Options 512 + - `:create` - Allow creating new records. 513 + - `:update` - Allow updating existing records. 514 + - `:delete` - Allow deleting records. 515 + - `:as_string` (optional) - If `true` (default), returns a scope string, 516 + otherwise returns a Permission struct. 517 + 518 + If `:as_string` is true a scope string is returned, otherwise the underlying 519 + Permission struct is returned. 520 + 521 + ## Examples 522 + 523 + # Full permission on a single record type (all actions enabled, actions omitted) 524 + iex> Atex.OAuth.Permission.repo("app.example.profile") 525 + "repo:app.example.profile" 526 + 527 + # Create only permission (other actions implicitly disabled) 528 + iex> Atex.OAuth.Permission.repo("app.example.post", create: true, as_string: false) 529 + %Atex.OAuth.Permission{ 530 + resource: "repo", 531 + positional: "app.example.post", 532 + parameters: [{"action", "create"}] 533 + } 534 + 535 + # Delete only permission 536 + iex> Atex.OAuth.Permission.repo("app.example.like", delete: true) 537 + "repo:app.example.like?action=delete" 538 + 539 + # Create and update only, delete implicitly disabled 540 + iex> Atex.OAuth.Permission.repo("app.example.repost", create: true, update: true) 541 + "repo:app.example.repost?action=update&action=create" 542 + 543 + # Multiple collections with full permissions (no options provided, actions omitted) 544 + iex> Atex.OAuth.Permission.repo(["app.example.profile", "app.example.post"]) 545 + "repo?collection=app.example.profile&collection=app.example.post" 546 + 547 + # Multiple collections with only update permission (as struct) 548 + iex> Atex.OAuth.Permission.repo(["app.example.like", "app.example.repost"], update: true, as_string: false) 549 + %Atex.OAuth.Permission{ 550 + resource: "repo", 551 + positional: nil, 552 + parameters: [ 553 + {"collection", "app.example.like"}, 554 + {"collection", "app.example.repost"}, 555 + {"action", "update"} 556 + ] 557 + } 558 + 559 + # Wildcard permission (all record types, all actions enabled, actions omitted) 560 + iex> Atex.OAuth.Permission.repo("*") 561 + "repo:*" 562 + """ 563 + @spec repo(String.t() | list(String.t()), list(repo_opt())) :: t() | String.t() 564 + def repo(collection_or_collections, actions \\ [create: true, update: true, delete: true]) 565 + 566 + def repo(_collection, []), 567 + do: 568 + raise( 569 + ArgumentError, 570 + ":actions must not be an empty list. If you want to have all actions enabled, either set them explicitly or remove the empty list argument." 571 + ) 572 + 573 + def repo(collection, actions) when is_binary(collection), do: repo([collection], actions) 574 + 575 + def repo(collections, actions) when is_list(collections) do 576 + actions = 577 + Keyword.validate!(actions, [:create, :update, :delete, as_string: true]) 578 + 579 + # Check if any action keys were explicitly provided 580 + has_explicit_actions = 581 + Keyword.has_key?(actions, :create) || 582 + Keyword.has_key?(actions, :update) || 583 + Keyword.has_key?(actions, :delete) 584 + 585 + # If no action keys provided, default all to true; otherwise use explicit values 586 + create = if has_explicit_actions, do: Keyword.get(actions, :create, false), else: true 587 + update = if has_explicit_actions, do: Keyword.get(actions, :update, false), else: true 588 + delete = if has_explicit_actions, do: Keyword.get(actions, :delete, false), else: true 589 + all_actions_true = create && update && delete 590 + 591 + as_string = Keyword.get(actions, :as_string) 592 + singular_collection = length(collections) == 1 593 + collection_parameters = Enum.map(collections, &{"collection", &1}) 594 + 595 + parameters = 596 + [] 597 + |> add_repo_param(:create, create, all_actions_true) 598 + |> add_repo_param(:update, update, all_actions_true) 599 + |> add_repo_param(:delete, delete, all_actions_true) 600 + |> add_repo_param(:collections, collection_parameters) 601 + 602 + struct = %__MODULE__{ 603 + resource: "repo", 604 + positional: if(singular_collection, do: hd(collections)), 605 + parameters: parameters 606 + } 607 + 608 + if as_string, do: to_string(struct), else: struct 609 + end 610 + 611 + # When all actions are true, omit them 612 + defp add_repo_param(list, _type, _value, true), do: list 613 + # Otherwise add them in 614 + defp add_repo_param(list, :create, true, false), do: [{"action", "create"} | list] 615 + defp add_repo_param(list, :update, true, false), do: [{"action", "update"} | list] 616 + defp add_repo_param(list, :delete, true, false), do: [{"action", "delete"} | list] 617 + 618 + # Catch-all for 4-arity version (must be before 3-arity) 619 + defp add_repo_param(list, _type, _value, _all_true), do: list 620 + 621 + defp add_repo_param(list, :collections, [_ | [_ | _]] = collections), 622 + do: Enum.concat(collections, list) 623 + 624 + defp add_repo_param(list, _type, _value), do: list 625 + 626 + @doc """ 627 + Creates an RPC permission for authenticated API requests to remote services. 628 + 629 + The permission is parameterised by the remote endpoint (`lxm`, short for 630 + "Lexicon Method") and the identity of the remote service (the audience, 631 + `aud`). Permissions must be restricted by at least one of these parameters. 632 + 633 + See the [AT Protocol documentation](https://atproto.com/specs/permission#rpc) 634 + for more information. 635 + 636 + ## Parameters 637 + - `lxm` - A single NSID string or list of NSID strings representing API 638 + endpoints. Use `"*"` for wildcard access to all endpoints. 639 + - `opts` - Keyword list of options. 640 + 641 + ## Options 642 + - `:aud` (semi-required) - Audience of API requests as a DID service 643 + reference (e.g., `"did:web:api.example.com#srvtype"`). Supports wildcard 644 + (`"*"`). 645 + - `:inherit_aud` (optional) - If `true`, the `aud` value will be inherited 646 + from permission set invocation context. Only used inside permission sets. 647 + - `:as_string` (optional) - If `true` (default), returns a scope string, 648 + otherwise returns a Permission struct. 649 + 650 + > #### Note {: .info} 651 + > 652 + > `aud` and `lxm` cannot both be wildcard. The permission must be restricted 653 + > by at least one of them. 654 + 655 + If `:as_string` is true a scope string is returned, otherwise the underlying 656 + Permission struct is returned. 657 + 658 + ## Examples 659 + 660 + # Single endpoint with wildcard audience (as string) 661 + iex> Atex.OAuth.Permission.rpc("app.example.moderation.createReport", aud: "*") 662 + "rpc:app.example.moderation.createReport?aud=*" 663 + 664 + # Multiple endpoints with specific service (as struct) 665 + iex> Atex.OAuth.Permission.rpc( 666 + ...> ["app.example.getFeed", "app.example.getProfile"], 667 + ...> aud: "did:web:api.example.com#svc_appview", 668 + ...> as_string: false 669 + ...> ) 670 + %Atex.OAuth.Permission{ 671 + resource: "rpc", 672 + positional: nil, 673 + parameters: [ 674 + {"aud", "did:web:api.example.com#svc_appview"}, 675 + {"lxm", "app.example.getFeed"}, 676 + {"lxm", "app.example.getProfile"} 677 + ] 678 + } 679 + 680 + # Wildcard method with specific service 681 + iex> Atex.OAuth.Permission.rpc("*", aud: "did:web:api.example.com#svc_appview") 682 + "rpc:*?aud=did:web:api.example.com%23svc_appview" 683 + 684 + # Single endpoint with inherited audience (for permission sets) 685 + iex> Atex.OAuth.Permission.rpc("app.example.getPreferences", inherit_aud: true) 686 + "rpc:app.example.getPreferences?inheritAud=true" 687 + 688 + """ 689 + @spec rpc(String.t() | list(String.t()), list(rpc_opt())) :: t() | String.t() 690 + def rpc(lxm_or_lxms, opts \\ []) 691 + def rpc(lxm, opts) when is_binary(lxm), do: rpc([lxm], opts) 692 + 693 + def rpc(lxms, opts) when is_list(lxms) do 694 + opts = Keyword.validate!(opts, aud: nil, inherit_aud: false, as_string: true) 695 + aud = Keyword.get(opts, :aud) 696 + inherit_aud = Keyword.get(opts, :inherit_aud) 697 + as_string = Keyword.get(opts, :as_string) 698 + 699 + # Validation: must have at least one of aud or inherit_aud 700 + cond do 701 + is_nil(aud) && !inherit_aud -> 702 + raise ArgumentError, 703 + "RPC permissions must specify either `:aud` or `:inheritAud` option." 704 + 705 + !is_nil(aud) && inherit_aud -> 706 + raise ArgumentError, 707 + "RPC permissions cannot specify both `:aud` and `:inheritAud` options." 708 + 709 + # Both lxm and aud cannot be wildcard 710 + length(lxms) == 1 && hd(lxms) == "*" && aud == "*" -> 711 + raise ArgumentError, "RPC permissions cannot have both wildcard `lxm` and wildcard `aud`." 712 + 713 + true -> 714 + singular_lxm = length(lxms) == 1 715 + lxm_parameters = Enum.map(lxms, &{"lxm", &1}) 716 + 717 + parameters = 718 + cond do 719 + inherit_aud && singular_lxm -> 720 + [{"inheritAud", "true"}] 721 + 722 + inherit_aud -> 723 + [{"inheritAud", "true"} | lxm_parameters] 724 + 725 + singular_lxm -> 726 + [{"aud", aud}] 727 + 728 + true -> 729 + [{"aud", aud} | lxm_parameters] 730 + end 731 + 732 + struct = %__MODULE__{ 733 + resource: "rpc", 734 + positional: if(singular_lxm, do: hd(lxms)), 735 + parameters: parameters 736 + } 737 + 738 + if as_string, do: to_string(struct), else: struct 739 + end 740 + end 741 + 742 + @doc """ 743 + Creates an include permission for referencing a permission set. 744 + 745 + Permission sets are Lexicon schemas that bundle together multiple permissions 746 + under a single NSID. This allows developers to request a group of related 747 + permissions with a single scope string, improving user experience by reducing 748 + the number of individual permissions that need to be reviewed. 749 + 750 + The `nsid` parameter is required and must be a valid NSID that resolves to a 751 + permission set Lexicon schema. An optional `aud` parameter can be used to specify 752 + the audience for any RPC permissions within the set that have `inheritAud: true`. 753 + 754 + See the [AT Protocol documentation](https://atproto.com/specs/permission#permission-sets) 755 + for more information. 756 + 757 + ## Parameters 758 + - `nsid` - The NSID of the permission set (e.g., "com.example.authBasicFeatures") 759 + - `opts` - Keyword list of options. 760 + 761 + ## Options 762 + - `:aud` (optional) - Audience of API requests as a DID service reference 763 + (e.g., "did:web:api.example.com#srvtype"). Supports wildcard (`"*"`). 764 + - `:as_string` (optional) - If `true` (default), returns a scope string, 765 + otherwise returns a Permission struct. 766 + 767 + If `:as_string` is true a scope string is returned, otherwise the underlying 768 + Permission struct is returned. 769 + 770 + ## Examples 771 + 772 + # Include a permission set (as string) 773 + iex> Atex.OAuth.Permission.include("com.example.authBasicFeatures") 774 + "include:com.example.authBasicFeatures" 775 + 776 + # Include a permission set with audience (as struct) 777 + iex> Atex.OAuth.Permission.include("com.example.authFull", aud: "did:web:api.example.com#svc_chat", as_string: false) 778 + %Atex.OAuth.Permission{ 779 + resource: "include", 780 + positional: "com.example.authFull", 781 + parameters: [{"aud", "did:web:api.example.com#svc_chat"}] 782 + } 783 + 784 + # Include a permission set with wildcard audience 785 + iex> Atex.OAuth.Permission.include("app.example.authFull", aud: "*") 786 + "include:app.example.authFull?aud=*" 787 + 788 + """ 789 + @spec include(String.t(), list(include_opt())) :: t() | String.t() 790 + def include(nsid, opts \\ []) do 791 + opts = Keyword.validate!(opts, aud: nil, as_string: true) 792 + aud = Keyword.get(opts, :aud) 793 + as_string = Keyword.get(opts, :as_string) 794 + 795 + parameters = if !is_nil(aud), do: [{"aud", aud}], else: [] 796 + 797 + struct = %__MODULE__{ 798 + resource: "include", 799 + positional: nsid, 800 + parameters: parameters 801 + } 802 + 803 + if as_string, do: to_string(struct), else: struct 804 + end 805 + end 806 + 807 + defimpl String.Chars, for: Atex.OAuth.Permission do 808 + def to_string(permission), do: Atex.OAuth.Permission.to_string(permission) 809 + end
+244
lib/atex/oauth/plug.ex
···
··· 1 + defmodule Atex.OAuth.Plug do 2 + @moduledoc """ 3 + Plug router for handling AT Protocol's OAuth flow. 4 + 5 + This module provides three endpoints: 6 + 7 + - `GET /login?handle=<handle>` - Initiates the OAuth authorization flow for a 8 + given handle 9 + - `GET /callback` - Handles the OAuth callback after user authorization 10 + - `GET /client-metadata.json` - Serves the OAuth client metadata 11 + 12 + ## Usage 13 + 14 + This module requires `Plug.Session` to be in your pipeline, as well as 15 + `secret_key_base` to have been set on your connections. Ideally it should be 16 + routed to via `Plug.Router.forward/2`, under a route like "/oauth". 17 + 18 + The plug requires a `:callback` option that must be an MFA tuple (Module, 19 + Function, Args). This callback is invoked after successful OAuth 20 + authentication, receiving the connection with the authenticated session data. 21 + 22 + ## Error Handling 23 + 24 + `Atex.OAuth.Error` exceptions are raised when errors occur during the OAuth 25 + flow (e.g. an invalid handle is provided, or validation failed). You should 26 + implement a `Plug.ErrorHandler` to catch and handle these exceptions 27 + gracefully. 28 + 29 + ## Example 30 + 31 + Example implementation showing how to set up the OAuth plug with proper 32 + session handling, error handling, and a callback function. 33 + 34 + defmodule ExampleOAuthPlug do 35 + use Plug.Router 36 + use Plug.ErrorHandler 37 + 38 + plug :put_secret_key_base 39 + 40 + plug Plug.Session, 41 + store: :cookie, 42 + key: "atex-oauth", 43 + signing_salt: "signing-salt" 44 + 45 + plug :match 46 + plug :dispatch 47 + 48 + forward "/oauth", to: Atex.OAuth.Plug, init_opts: [callback: {__MODULE__, :oauth_callback, []}] 49 + 50 + def oauth_callback(conn) do 51 + # Handle successful OAuth authentication 52 + conn 53 + |> put_resp_header("Location", "/dashboard") 54 + |> resp(307, "") 55 + |> send_resp() 56 + end 57 + 58 + def put_secret_key_base(conn, _) do 59 + put_in( 60 + conn.secret_key_base, 61 + "very long key base with at least 64 bytes" 62 + ) 63 + end 64 + 65 + # Error handler for OAuth exceptions 66 + @impl Plug.ErrorHandler 67 + def handle_errors(conn, %{kind: :error, reason: %Atex.OAuth.Error{} = error, stack: _stack}) do 68 + status = case error.reason do 69 + reason when reason in [:missing_handle, :invalid_handle, :invalid_callback_request, :issuer_mismatch] -> 400 70 + _ -> 500 71 + end 72 + 73 + conn 74 + |> put_resp_content_type("text/plain") 75 + |> send_resp(status, error.message) 76 + end 77 + 78 + # Fallback for other errors 79 + def handle_errors(conn, %{kind: _kind, reason: _reason, stack: _stack}) do 80 + send_resp(conn, conn.status, "Something went wrong") 81 + end 82 + end 83 + 84 + ## Session Storage 85 + 86 + After successful authentication, the plug stores these in the session: 87 + 88 + - `:tokens` - The access token response containing access_token, 89 + refresh_token, did, and expires_at 90 + - `:dpop_nonce` - 91 + - `:dpop_key` - The DPoP JWK for generating DPoP proofs 92 + """ 93 + require Logger 94 + use Plug.Router 95 + require Plug.Router 96 + alias Atex.OAuth 97 + alias Atex.{IdentityResolver, IdentityResolver.DIDDocument} 98 + 99 + @oauth_cookie_opts [path: "/", http_only: true, secure: true, same_site: "lax", max_age: 600] 100 + @session_name :atex_session 101 + 102 + def init(opts) do 103 + callback = Keyword.get(opts, :callback, nil) 104 + 105 + if !match?({_module, _function, _args}, callback) do 106 + raise "expected callback to be a MFA tuple" 107 + end 108 + 109 + opts 110 + end 111 + 112 + def call(conn, opts) do 113 + conn 114 + |> put_private(:atex_oauth_opts, opts) 115 + |> super(opts) 116 + end 117 + 118 + plug :match 119 + plug :dispatch 120 + 121 + get "/login" do 122 + conn = fetch_query_params(conn) 123 + handle = conn.query_params["handle"] 124 + 125 + if !handle do 126 + raise Atex.OAuth.Error, 127 + message: "Handle query parameter is required", 128 + reason: :missing_handle 129 + end 130 + 131 + case IdentityResolver.resolve(handle) do 132 + {:ok, identity} -> 133 + pds = DIDDocument.get_pds_endpoint(identity.document) 134 + {:ok, authz_server} = OAuth.get_authorization_server(pds) 135 + {:ok, authz_metadata} = OAuth.get_authorization_server_metadata(authz_server) 136 + state = OAuth.create_nonce() 137 + code_verifier = OAuth.create_nonce() 138 + 139 + case OAuth.create_authorization_url( 140 + authz_metadata, 141 + state, 142 + code_verifier, 143 + handle 144 + ) do 145 + {:ok, authz_url} -> 146 + conn 147 + |> put_resp_cookie("state", state, @oauth_cookie_opts) 148 + |> put_resp_cookie("code_verifier", code_verifier, @oauth_cookie_opts) 149 + |> put_resp_cookie("issuer", authz_metadata.issuer, @oauth_cookie_opts) 150 + |> put_resp_header("location", authz_url) 151 + |> send_resp(307, "") 152 + 153 + {:error, _err} -> 154 + raise Atex.OAuth.Error, 155 + message: "Failed to create authorization URL", 156 + reason: :authorization_url_failed 157 + end 158 + 159 + _err -> 160 + raise Atex.OAuth.Error, message: "Invalid or unresolvable handle", reason: :invalid_handle 161 + end 162 + end 163 + 164 + get "/client-metadata.json" do 165 + conn 166 + |> put_resp_content_type("application/json") 167 + |> send_resp(200, JSON.encode_to_iodata!(OAuth.create_client_metadata())) 168 + end 169 + 170 + get "/callback" do 171 + conn = conn |> fetch_query_params() |> fetch_session() 172 + callback = Keyword.get(conn.private.atex_oauth_opts, :callback) 173 + cookies = get_cookies(conn) 174 + stored_state = cookies["state"] 175 + stored_code_verifier = cookies["code_verifier"] 176 + stored_issuer = cookies["issuer"] 177 + 178 + code = conn.query_params["code"] 179 + state = conn.query_params["state"] 180 + 181 + if !stored_state || !stored_code_verifier || !stored_issuer || (!code || !state) || 182 + stored_state != state do 183 + raise Atex.OAuth.Error, 184 + message: "Invalid callback request: missing or mismatched state/code parameters", 185 + reason: :invalid_callback_request 186 + end 187 + 188 + with {:ok, authz_metadata} <- OAuth.get_authorization_server_metadata(stored_issuer), 189 + dpop_key <- JOSE.JWK.generate_key({:ec, "P-256"}), 190 + {:ok, tokens, nonce} <- 191 + OAuth.validate_authorization_code( 192 + authz_metadata, 193 + dpop_key, 194 + code, 195 + stored_code_verifier 196 + ), 197 + {:ok, identity} <- IdentityResolver.resolve(tokens.did), 198 + # Make sure pds' issuer matches the stored one (just in case) 199 + pds <- DIDDocument.get_pds_endpoint(identity.document), 200 + {:ok, authz_server} <- OAuth.get_authorization_server(pds), 201 + true <- authz_server == stored_issuer do 202 + session = %OAuth.Session{ 203 + iss: authz_server, 204 + aud: pds, 205 + sub: tokens.did, 206 + access_token: tokens.access_token, 207 + refresh_token: tokens.refresh_token, 208 + expires_at: tokens.expires_at, 209 + dpop_key: dpop_key, 210 + dpop_nonce: nonce 211 + } 212 + 213 + case OAuth.SessionStore.insert(session) do 214 + :ok -> 215 + conn = 216 + conn 217 + |> delete_resp_cookie("state", @oauth_cookie_opts) 218 + |> delete_resp_cookie("code_verifier", @oauth_cookie_opts) 219 + |> delete_resp_cookie("issuer", @oauth_cookie_opts) 220 + |> put_session(@session_name, tokens.did) 221 + 222 + {mod, func, args} = callback 223 + apply(mod, func, [conn | args]) 224 + 225 + {:error, reason} -> 226 + raise Atex.OAuth.Error, 227 + message: "Failed to store OAuth session, reason: #{reason}", 228 + reason: :session_store_failed 229 + end 230 + else 231 + false -> 232 + raise Atex.OAuth.Error, 233 + message: "OAuth issuer does not match PDS' authorization server", 234 + reason: :issuer_mismatch 235 + 236 + _err -> 237 + raise Atex.OAuth.Error, 238 + message: "Failed to validate authorization code or token", 239 + reason: :token_validation_failed 240 + end 241 + end 242 + 243 + # TODO: logout route 244 + end
+50
lib/atex/oauth/session.ex
···
··· 1 + defmodule Atex.OAuth.Session do 2 + @moduledoc """ 3 + Struct representing an active OAuth session for an AT Protocol user. 4 + 5 + Contains all the necessary credentials and metadata to make authenticated 6 + requests to a user's PDS using OAuth with DPoP. 7 + 8 + ## Fields 9 + 10 + - `:iss` - Authorization server issuer URL 11 + - `:aud` - PDS endpoint URL (audience) 12 + - `:sub` - User's DID (subject), used as the session key 13 + - `:access_token` - OAuth access token for authenticating requests 14 + - `:refresh_token` - OAuth refresh token for obtaining new access tokens 15 + - `:expires_at` - When the current access token expires (NaiveDateTime in UTC) 16 + - `:dpop_key` - DPoP signing key (Demonstrating Proof-of-Possession) 17 + - `:dpop_nonce` - Server-provided nonce for DPoP proofs (optional, updated per-request) 18 + 19 + ## Usage 20 + 21 + Sessions are typically created during the OAuth flow and stored in a `SessionStore`. 22 + They should not be created manually in most cases. 23 + 24 + session = %Atex.OAuth.Session{ 25 + iss: "https://bsky.social", 26 + aud: "https://puffball.us-east.host.bsky.network", 27 + sub: "did:plc:abc123", 28 + access_token: "...", 29 + refresh_token: "...", 30 + expires_at: ~N[2026-01-04 12:00:00], 31 + dpop_key: dpop_key, 32 + dpop_nonce: "server-nonce" 33 + } 34 + """ 35 + use TypedStruct 36 + 37 + typedstruct enforce: true do 38 + # Authz server issuer 39 + field :iss, String.t() 40 + # PDS endpoint 41 + field :aud, String.t() 42 + # User's DID 43 + field :sub, String.t() 44 + field :access_token, String.t() 45 + field :refresh_token, String.t() 46 + field :expires_at, NaiveDateTime.t() 47 + field :dpop_key, JOSE.JWK.t() 48 + field :dpop_nonce, String.t() | nil, enforce: false 49 + end 50 + end
+121
lib/atex/oauth/session_store/dets.ex
···
··· 1 + defmodule Atex.OAuth.SessionStore.DETS do 2 + @moduledoc """ 3 + DETS implementation for `Atex.OAuth.SessionStore`. 4 + 5 + This is recommended for single-node production deployments, as sessions will 6 + persist on disk between application restarts. For more complex, multi-node 7 + deployments, consider making a custom implementation using Redis or some other 8 + distributed store. 9 + 10 + ## Configuration 11 + 12 + By default the DETS file is stored at `priv/dets/atex_oauth_sessions.dets` 13 + relative to where your application is running. You can configure the file path 14 + in your `config.exs`: 15 + 16 + config :atex, Atex.OAuth.SessionStore.DETS, 17 + file_path: "/var/lib/myapp/sessions.dets" 18 + 19 + Parent directories will be created as necessary if possible. 20 + """ 21 + 22 + alias Atex.OAuth.Session 23 + require Logger 24 + use Supervisor 25 + 26 + @behaviour Atex.OAuth.SessionStore 27 + @table :atex_oauth_sessions 28 + @default_file "priv/dets/atex_oauth_sessions.dets" 29 + 30 + def start_link(opts) do 31 + Supervisor.start_link(__MODULE__, opts) 32 + end 33 + 34 + @impl Supervisor 35 + def init(_opts) do 36 + dets_file = 37 + case Application.get_env(:atex, __MODULE__, [])[:file_path] do 38 + nil -> 39 + @default_file 40 + 41 + path -> 42 + path 43 + end 44 + 45 + # Ensure parent directory exists 46 + dets_file 47 + |> Path.dirname() 48 + |> File.mkdir_p!() 49 + 50 + case :dets.open_file(@table, file: String.to_charlist(dets_file), type: :set) do 51 + {:ok, @table} -> 52 + Logger.info("DETS session store opened: #{dets_file}") 53 + Supervisor.init([], strategy: :one_for_one) 54 + 55 + {:error, reason} -> 56 + Logger.error("Failed to open DETS file: #{inspect(reason)}") 57 + raise "Failed to initialize DETS session store: #{inspect(reason)}" 58 + end 59 + end 60 + 61 + @doc """ 62 + Insert a session into the DETS table. 63 + 64 + Returns `:ok` on success, `{:error, reason}` if an unexpected error occurs. 65 + """ 66 + @impl Atex.OAuth.SessionStore 67 + @spec insert(String.t(), Session.t()) :: :ok | {:error, atom()} 68 + def insert(key, session) do 69 + case :dets.insert(@table, {key, session}) do 70 + :ok -> 71 + :ok 72 + 73 + {:error, reason} -> 74 + Logger.error("DETS insert failed: #{inspect(reason)}") 75 + {:error, reason} 76 + end 77 + end 78 + 79 + @doc """ 80 + Update a session in the DETS table. 81 + 82 + In DETS, this is the same as insert - it replaces the existing entry. 83 + """ 84 + @impl Atex.OAuth.SessionStore 85 + @spec update(String.t(), Session.t()) :: :ok | {:error, atom()} 86 + def update(key, session) do 87 + insert(key, session) 88 + end 89 + 90 + @doc """ 91 + Retrieve a session from the DETS table. 92 + 93 + Returns `{:ok, session}` if found, `{:error, :not_found}` otherwise. 94 + """ 95 + @impl Atex.OAuth.SessionStore 96 + @spec get(String.t()) :: {:ok, Session.t()} | {:error, atom()} 97 + def get(key) do 98 + case :dets.lookup(@table, key) do 99 + [{_key, session}] -> {:ok, session} 100 + [] -> {:error, :not_found} 101 + end 102 + end 103 + 104 + @doc """ 105 + Delete a session from the DETS table. 106 + 107 + Returns `:ok` if deleted, `:noop` if the session didn't exist. 108 + """ 109 + @impl Atex.OAuth.SessionStore 110 + @spec delete(String.t()) :: :ok | :error | :noop 111 + def delete(key) do 112 + case get(key) do 113 + {:ok, _session} -> 114 + :dets.delete(@table, key) 115 + :ok 116 + 117 + {:error, :not_found} -> 118 + :noop 119 + end 120 + end 121 + end
+88
lib/atex/oauth/session_store/ets.ex
···
··· 1 + defmodule Atex.OAuth.SessionStore.ETS do 2 + @moduledoc """ 3 + In-memory, ETS implementation for `Atex.OAuth.SessionStore`. 4 + 5 + This is moreso intended for testing or some occasion where you want the 6 + session store to be volatile for some reason. It's recommended you use 7 + `Atex.OAuth.SessionStore.DETS` for single-node production deployments. 8 + """ 9 + 10 + alias Atex.OAuth.Session 11 + require Logger 12 + use Supervisor 13 + 14 + @behaviour Atex.OAuth.SessionStore 15 + @table :atex_oauth_sessions 16 + 17 + def start_link(opts) do 18 + Supervisor.start_link(__MODULE__, opts) 19 + end 20 + 21 + @impl Supervisor 22 + def init(_opts) do 23 + :ets.new(@table, [:set, :public, :named_table]) 24 + Supervisor.init([], strategy: :one_for_one) 25 + end 26 + 27 + @doc """ 28 + Insert a session into the ETS table. 29 + 30 + Returns `:ok` on success, `{:error, :ets}` if an unexpected error occurs. 31 + """ 32 + @impl Atex.OAuth.SessionStore 33 + @spec insert(String.t(), Session.t()) :: :ok | {:error, atom()} 34 + def insert(key, session) do 35 + try do 36 + :ets.insert(@table, {key, session}) 37 + :ok 38 + rescue 39 + # Freak accidents can occur 40 + e -> 41 + Logger.error(Exception.format(:error, e, __STACKTRACE__)) 42 + {:error, :ets} 43 + end 44 + end 45 + 46 + @doc """ 47 + Update a session in the ETS table. 48 + 49 + In ETS, this is the same as insert - it replaces the existing entry. 50 + """ 51 + @impl Atex.OAuth.SessionStore 52 + @spec update(String.t(), Session.t()) :: :ok | {:error, atom()} 53 + def update(key, session) do 54 + insert(key, session) 55 + end 56 + 57 + @doc """ 58 + Retrieve a session from the ETS table. 59 + 60 + Returns `{:ok, session}` if found, `{:error, :not_found}` otherwise. 61 + """ 62 + @impl Atex.OAuth.SessionStore 63 + @spec get(String.t()) :: {:ok, Session.t()} | {:error, atom()} 64 + def get(key) do 65 + case :ets.lookup(@table, key) do 66 + [{_key, session}] -> {:ok, session} 67 + [] -> {:error, :not_found} 68 + end 69 + end 70 + 71 + @doc """ 72 + Delete a session from the ETS table. 73 + 74 + Returns `:ok` if deleted, `:noop` if the session didn't exist. 75 + """ 76 + @impl Atex.OAuth.SessionStore 77 + @spec delete(String.t()) :: :ok | :error | :noop 78 + def delete(key) do 79 + case get(key) do 80 + {:ok, _session} -> 81 + :ets.delete(@table, key) 82 + :ok 83 + 84 + {:error, :not_found} -> 85 + :noop 86 + end 87 + end 88 + end
+119
lib/atex/oauth/session_store.ex
···
··· 1 + defmodule Atex.OAuth.SessionStore do 2 + @moduledoc """ 3 + Storage interface for OAuth sessions. 4 + 5 + Provides a behaviour for implementing session storage backends, and functions 6 + to operate the backend using `Atex.OAuth.Session` 7 + 8 + ## Configuration 9 + 10 + The default implementation for the store is `Atex.OAuth.SessionStore.DETS`; 11 + this can be changed to a custom implementation in your config.exs: 12 + 13 + config :atex, :session_store, Atex.OAuth.SessionStore.ETS 14 + 15 + DETS is the default implementation as it provides simple, on-disk storage for 16 + sessions so they don't get discarded on an application restart, but a regular 17 + ETS implementation is also provided out-of-the-box for testing or other 18 + circumstances. 19 + 20 + For multi-node deployments, you can write your own implementation using a 21 + custom backend, such as Redis, by implementing the behaviour callbacks. 22 + 23 + ## Usage 24 + 25 + Sessions are keyed by the user's DID (`sub` field). 26 + 27 + session = %Atex.OAuth.Session{ 28 + iss: "https://bsky.social", 29 + aud: "https://puffball.us-east.host.bsky.network", 30 + sub: "did:plc:abc123", 31 + access_token: "...", 32 + refresh_token: "...", 33 + expires_at: ~N[2026-01-04 12:00:00], 34 + dpop_key: dpop_key, 35 + dpop_nonce: "server-nonce" 36 + } 37 + 38 + # Insert a new session 39 + :ok = Atex.OAuth.SessionStore.insert(session) 40 + 41 + # Retrieve a session 42 + {:ok, session} = Atex.OAuth.SessionStore.get("did:plc:abc123") 43 + 44 + # Update an existing session (e.g., after token refresh) 45 + updated_session = %{session | access_token: new_token} 46 + :ok = Atex.OAuth.SessionStore.update(updated_session) 47 + 48 + # Delete a session 49 + Atex.OAuth.SessionStore.delete(session) 50 + """ 51 + 52 + @store Application.compile_env(:atex, :session_store, Atex.OAuth.SessionStore.DETS) 53 + 54 + @doc """ 55 + Retrieve a session by DID. 56 + 57 + Returns `{:ok, session}` if found, `{:error, :not_found}` otherwise. 58 + """ 59 + @callback get(key :: String.t()) :: {:ok, Atex.OAuth.Session.t()} | {:error, atom()} 60 + 61 + @doc """ 62 + Insert a new session. 63 + 64 + The key is the user's DID (`session.sub`). Returns `:ok` on success. 65 + """ 66 + @callback insert(key :: String.t(), session :: Atex.OAuth.Session.t()) :: 67 + :ok | {:error, atom()} 68 + 69 + @doc """ 70 + Update an existing session. 71 + 72 + Replaces the existing session data for the given key. Returns `:ok` on success. 73 + """ 74 + @callback update(key :: String.t(), session :: Atex.OAuth.Session.t()) :: 75 + :ok | {:error, atom()} 76 + 77 + @doc """ 78 + Delete a session. 79 + 80 + Returns `:ok` if deleted, `:noop` if the session didn't exist, :error if it failed. 81 + """ 82 + @callback delete(key :: String.t()) :: :ok | :error | :noop 83 + 84 + @callback child_spec(any()) :: Supervisor.child_spec() 85 + 86 + defdelegate child_spec(opts), to: @store 87 + 88 + @doc """ 89 + Retrieve a session by DID. 90 + """ 91 + @spec get(String.t()) :: {:ok, Atex.OAuth.Session.t()} | {:error, atom()} 92 + def get(key) do 93 + @store.get(key) 94 + end 95 + 96 + @doc """ 97 + Insert a new session. 98 + """ 99 + @spec insert(Atex.OAuth.Session.t()) :: :ok | {:error, atom()} 100 + def insert(session) do 101 + @store.insert(session.sub, session) 102 + end 103 + 104 + @doc """ 105 + Update an existing session. 106 + """ 107 + @spec update(Atex.OAuth.Session.t()) :: :ok | {:error, atom()} 108 + def update(session) do 109 + @store.update(session.sub, session) 110 + end 111 + 112 + @doc """ 113 + Delete a session. 114 + """ 115 + @callback delete(Atex.OAuth.Session.t()) :: :ok | :error | :noop 116 + def delete(session) do 117 + @store.delete(session.sub) 118 + end 119 + end
+582
lib/atex/oauth.ex
···
··· 1 + defmodule Atex.OAuth do 2 + @moduledoc """ 3 + OAuth 2.0 implementation for AT Protocol authentication. 4 + 5 + This module provides utilities for implementing OAuth flows compliant with the 6 + AT Protocol specification. It includes support for: 7 + 8 + - Pushed Authorization Requests (PAR) 9 + - DPoP (Demonstration of Proof of Possession) tokens 10 + - JWT client assertions 11 + - PKCE (Proof Key for Code Exchange) 12 + - Token refresh 13 + - Handle to PDS resolution 14 + 15 + ## Configuration 16 + 17 + See `Atex.Config.OAuth` module for configuration documentation. 18 + 19 + ## Usage Example 20 + 21 + iex> pds = "https://bsky.social" 22 + iex> login_hint = "example.com" 23 + iex> {:ok, authz_server} = Atex.OAuth.get_authorization_server(pds) 24 + iex> {:ok, authz_metadata} = Atex.OAuth.get_authorization_server_metadata(authz_server) 25 + iex> state = Atex.OAuth.create_nonce() 26 + iex> code_verifier = Atex.OAuth.create_nonce() 27 + iex> {:ok, auth_url} = Atex.OAuth.create_authorization_url( 28 + authz_metadata, 29 + state, 30 + code_verifier, 31 + login_hint 32 + ) 33 + """ 34 + 35 + @type authorization_metadata() :: %{ 36 + issuer: String.t(), 37 + par_endpoint: String.t(), 38 + token_endpoint: String.t(), 39 + authorization_endpoint: String.t() 40 + } 41 + 42 + @type tokens() :: %{ 43 + access_token: String.t(), 44 + refresh_token: String.t(), 45 + did: String.t(), 46 + expires_at: NaiveDateTime.t() 47 + } 48 + 49 + alias Atex.Config.OAuth, as: Config 50 + 51 + @doc """ 52 + Get a map cnotaining the client metadata information needed for an 53 + authorization server to validate this client. 54 + """ 55 + @spec create_client_metadata() :: map() 56 + def create_client_metadata() do 57 + key = Config.get_key() 58 + {_, jwk} = key |> JOSE.JWK.to_public_map() 59 + jwk = Map.merge(jwk, %{use: "sig", kid: key.fields["kid"]}) 60 + 61 + %{ 62 + client_id: Config.client_id(), 63 + redirect_uris: [Config.redirect_uri() | Config.extra_redirect_uris()], 64 + application_type: "web", 65 + grant_types: ["authorization_code", "refresh_token"], 66 + scope: Config.scopes(), 67 + response_type: ["code"], 68 + token_endpoint_auth_method: "private_key_jwt", 69 + token_endpoint_auth_signing_alg: "ES256", 70 + dpop_bound_access_tokens: true, 71 + jwks: %{keys: [jwk]} 72 + } 73 + end 74 + 75 + @doc """ 76 + Retrieves the configured JWT private key for signing client assertions. 77 + 78 + Loads the private key from configuration, decodes the base64-encoded DER data, 79 + and creates a JOSE JWK structure with the key ID field set. 80 + 81 + ## Returns 82 + 83 + A `JOSE.JWK` struct containing the private key and key identifier. 84 + 85 + ## Raises 86 + 87 + * `Application.Env.Error` if the private_key or key_id configuration is missing 88 + 89 + ## Examples 90 + 91 + key = OAuth.get_key() 92 + key = OAuth.get_key() 93 + """ 94 + @spec get_key() :: JOSE.JWK.t() 95 + def get_key(), do: Config.get_key() 96 + 97 + @doc false 98 + @spec random_b64(integer()) :: String.t() 99 + def random_b64(length) do 100 + :crypto.strong_rand_bytes(length) 101 + |> Base.url_encode64(padding: false) 102 + end 103 + 104 + @doc false 105 + @spec create_nonce() :: String.t() 106 + def create_nonce(), do: random_b64(32) 107 + 108 + @doc """ 109 + Create an OAuth authorization URL for a PDS. 110 + 111 + Submits a PAR request to the authorization server and constructs the 112 + authorization URL with the returned request URI. Supports PKCE, DPoP, and 113 + client assertions as required by the AT Protocol. 114 + 115 + ## Parameters 116 + 117 + - `authz_metadata` - Authorization server metadata containing endpoints, fetched from `get_authorization_server_metadata/1` 118 + - `state` - Random token for session validation 119 + - `code_verifier` - PKCE code verifier 120 + - `login_hint` - User identifier (handle or DID) for pre-filled login 121 + 122 + ## Returns 123 + 124 + - `{:ok, authorization_url}` - Successfully created authorization URL 125 + - `{:ok, :invalid_par_response}` - Server respondend incorrectly to the request 126 + - `{:error, reason}` - Error creating authorization URL 127 + """ 128 + @spec create_authorization_url( 129 + authorization_metadata(), 130 + String.t(), 131 + String.t(), 132 + String.t() 133 + ) :: {:ok, String.t()} | {:error, any()} 134 + def create_authorization_url( 135 + authz_metadata, 136 + state, 137 + code_verifier, 138 + login_hint 139 + ) do 140 + code_challenge = :crypto.hash(:sha256, code_verifier) |> Base.url_encode64(padding: false) 141 + key = get_key() 142 + 143 + client_assertion = 144 + create_client_assertion(key, Config.client_id(), authz_metadata.issuer) 145 + 146 + body = 147 + %{ 148 + response_type: "code", 149 + client_id: Config.client_id(), 150 + redirect_uri: Config.redirect_uri(), 151 + state: state, 152 + code_challenge_method: "S256", 153 + code_challenge: code_challenge, 154 + scope: Config.scopes(), 155 + client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", 156 + client_assertion: client_assertion, 157 + login_hint: login_hint 158 + } 159 + 160 + case Req.post(authz_metadata.par_endpoint, form: body) do 161 + {:ok, %{body: %{"request_uri" => request_uri}}} -> 162 + query = 163 + %{client_id: Config.client_id(), request_uri: request_uri} 164 + |> URI.encode_query() 165 + 166 + {:ok, "#{authz_metadata.authorization_endpoint}?#{query}"} 167 + 168 + {:ok, _} -> 169 + {:error, :invalid_par_response} 170 + 171 + err -> 172 + err 173 + end 174 + end 175 + 176 + @doc """ 177 + Exchange an OAuth authorization code for a set of access and refresh tokens. 178 + 179 + Validates the authorization code by submitting it to the token endpoint along with 180 + the PKCE code verifier and client assertion. Returns access tokens for making authenticated 181 + requests to the relevant user's PDS. 182 + 183 + ## Parameters 184 + 185 + - `authz_metadata` - Authorization server metadata containing token endpoint 186 + - `dpop_key` - JWK for DPoP token generation 187 + - `code` - Authorization code from OAuth callback 188 + - `code_verifier` - PKCE code verifier from authorization flow 189 + 190 + ## Returns 191 + 192 + - `{:ok, tokens, nonce}` - Successfully obtained tokens with returned DPoP nonce 193 + - `{:error, reason}` - Error exchanging code for tokens 194 + """ 195 + @spec validate_authorization_code( 196 + authorization_metadata(), 197 + JOSE.JWK.t(), 198 + String.t(), 199 + String.t() 200 + ) :: {:ok, tokens(), String.t()} | {:error, any()} 201 + def validate_authorization_code( 202 + authz_metadata, 203 + dpop_key, 204 + code, 205 + code_verifier 206 + ) do 207 + key = get_key() 208 + 209 + client_assertion = 210 + create_client_assertion(key, Config.client_id(), authz_metadata.issuer) 211 + 212 + body = 213 + %{ 214 + grant_type: "authorization_code", 215 + client_id: Config.client_id(), 216 + redirect_uri: Config.redirect_uri(), 217 + code: code, 218 + code_verifier: code_verifier, 219 + client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", 220 + client_assertion: client_assertion 221 + } 222 + 223 + Req.new(method: :post, url: authz_metadata.token_endpoint, form: body) 224 + |> send_oauth_dpop_request(dpop_key) 225 + |> case do 226 + {:ok, 227 + %{ 228 + "access_token" => access_token, 229 + "refresh_token" => refresh_token, 230 + "expires_in" => expires_in, 231 + "sub" => did 232 + }, nonce} -> 233 + expires_at = NaiveDateTime.utc_now() |> NaiveDateTime.add(expires_in, :second) 234 + 235 + {:ok, 236 + %{ 237 + access_token: access_token, 238 + refresh_token: refresh_token, 239 + did: did, 240 + expires_at: expires_at 241 + }, nonce} 242 + 243 + err -> 244 + err 245 + end 246 + end 247 + 248 + def refresh_token(refresh_token, dpop_key, issuer, token_endpoint) do 249 + key = get_key() 250 + 251 + client_assertion = 252 + create_client_assertion(key, Config.client_id(), issuer) 253 + 254 + body = %{ 255 + grant_type: "refresh_token", 256 + refresh_token: refresh_token, 257 + client_id: Config.client_id(), 258 + client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", 259 + client_assertion: client_assertion 260 + } 261 + 262 + Req.new(method: :post, url: token_endpoint, form: body) 263 + |> send_oauth_dpop_request(dpop_key) 264 + |> case do 265 + {:ok, 266 + %{ 267 + "access_token" => access_token, 268 + "refresh_token" => refresh_token, 269 + "expires_in" => expires_in, 270 + "sub" => did 271 + }, nonce} -> 272 + expires_at = NaiveDateTime.utc_now() |> NaiveDateTime.add(expires_in, :second) 273 + 274 + {:ok, 275 + %{ 276 + access_token: access_token, 277 + refresh_token: refresh_token, 278 + did: did, 279 + expires_at: expires_at 280 + }, nonce} 281 + 282 + err -> 283 + err 284 + end 285 + end 286 + 287 + @doc """ 288 + Fetch the authorization server for a given Personal Data Server (PDS). 289 + 290 + Makes a request to the PDS's `.well-known/oauth-protected-resource` endpoint 291 + to discover the associated authorization server that should be used for the 292 + OAuth flow. Results are cached for 1 hour to reduce load on third-party PDSs. 293 + 294 + ## Parameters 295 + 296 + - `pds_host` - Base URL of the PDS (e.g., "https://bsky.social") 297 + - `fresh` - If `true`, bypasses the cache and fetches fresh data (default: `false`) 298 + 299 + ## Returns 300 + 301 + - `{:ok, authorization_server}` - Successfully discovered authorization 302 + server URL 303 + - `{:error, :invalid_metadata}` - Server returned invalid metadata 304 + - `{:error, reason}` - Error discovering authorization server 305 + """ 306 + @spec get_authorization_server(String.t(), boolean()) :: {:ok, String.t()} | {:error, any()} 307 + def get_authorization_server(pds_host, fresh \\ false) do 308 + if fresh do 309 + fetch_authorization_server(pds_host) 310 + else 311 + case Atex.OAuth.Cache.get_authorization_server(pds_host) do 312 + {:ok, authz_server} -> 313 + {:ok, authz_server} 314 + 315 + {:error, :not_found} -> 316 + fetch_authorization_server(pds_host) 317 + end 318 + end 319 + end 320 + 321 + defp fetch_authorization_server(pds_host) do 322 + result = 323 + "#{pds_host}/.well-known/oauth-protected-resource" 324 + |> Req.get() 325 + |> case do 326 + # TODO: what to do when multiple authorization servers? 327 + {:ok, %{body: %{"authorization_servers" => [authz_server | _]}}} -> {:ok, authz_server} 328 + {:ok, _} -> {:error, :invalid_metadata} 329 + err -> err 330 + end 331 + 332 + case result do 333 + {:ok, authz_server} -> 334 + Atex.OAuth.Cache.set_authorization_server(pds_host, authz_server) 335 + {:ok, authz_server} 336 + 337 + error -> 338 + error 339 + end 340 + end 341 + 342 + @doc """ 343 + Fetch the metadata for an OAuth authorization server. 344 + 345 + Retrieves the metadata from the authorization server's 346 + `.well-known/oauth-authorization-server` endpoint, providing endpoint URLs 347 + required for the OAuth flow. Results are cached for 1 hour to reduce load on 348 + third-party PDSs. 349 + 350 + ## Parameters 351 + 352 + - `issuer` - Authorization server issuer URL 353 + - `fresh` - If `true`, bypasses the cache and fetches fresh data (default: `false`) 354 + 355 + ## Returns 356 + 357 + - `{:ok, metadata}` - Successfully retrieved authorization server metadata 358 + - `{:error, :invalid_metadata}` - Server returned invalid metadata 359 + - `{:error, :invalid_issuer}` - Issuer mismatch in metadata 360 + - `{:error, any()}` - Other error fetching metadata 361 + """ 362 + @spec get_authorization_server_metadata(String.t(), boolean()) :: 363 + {:ok, authorization_metadata()} | {:error, any()} 364 + def get_authorization_server_metadata(issuer, fresh \\ false) do 365 + if fresh do 366 + fetch_authorization_server_metadata(issuer) 367 + else 368 + case Atex.OAuth.Cache.get_authorization_server_metadata(issuer) do 369 + {:ok, metadata} -> 370 + {:ok, metadata} 371 + 372 + {:error, :not_found} -> 373 + fetch_authorization_server_metadata(issuer) 374 + end 375 + end 376 + end 377 + 378 + defp fetch_authorization_server_metadata(issuer) do 379 + result = 380 + "#{issuer}/.well-known/oauth-authorization-server" 381 + |> Req.get() 382 + |> case do 383 + {:ok, 384 + %{ 385 + body: %{ 386 + "issuer" => metadata_issuer, 387 + "pushed_authorization_request_endpoint" => par_endpoint, 388 + "token_endpoint" => token_endpoint, 389 + "authorization_endpoint" => authorization_endpoint 390 + } 391 + }} -> 392 + if issuer != metadata_issuer do 393 + {:error, :invaild_issuer} 394 + else 395 + {:ok, 396 + %{ 397 + issuer: metadata_issuer, 398 + par_endpoint: par_endpoint, 399 + token_endpoint: token_endpoint, 400 + authorization_endpoint: authorization_endpoint 401 + }} 402 + end 403 + 404 + {:ok, _} -> 405 + {:error, :invalid_metadata} 406 + 407 + err -> 408 + err 409 + end 410 + 411 + case result do 412 + {:ok, metadata} -> 413 + Atex.OAuth.Cache.set_authorization_server_metadata(issuer, metadata) 414 + {:ok, metadata} 415 + 416 + error -> 417 + error 418 + end 419 + end 420 + 421 + @spec send_oauth_dpop_request(Req.Request.t(), JOSE.JWK.t(), String.t() | nil) :: 422 + {:ok, map(), String.t()} | {:error, any(), String.t()} 423 + def send_oauth_dpop_request(request, dpop_key, nonce \\ nil) do 424 + dpop_token = create_dpop_token(dpop_key, request, nonce) 425 + 426 + request 427 + |> Req.Request.put_header("dpop", dpop_token) 428 + |> Req.request() 429 + |> case do 430 + {:ok, resp} -> 431 + dpop_nonce = 432 + case resp.headers["dpop-nonce"] do 433 + [new_nonce | _] -> new_nonce 434 + _ -> nonce 435 + end 436 + 437 + cond do 438 + resp.status == 200 -> 439 + {:ok, resp.body, dpop_nonce} 440 + 441 + resp.body["error"] === "use_dpop_nonce" -> 442 + dpop_token = create_dpop_token(dpop_key, request, dpop_nonce) 443 + 444 + request 445 + |> Req.Request.put_header("dpop", dpop_token) 446 + |> Req.request() 447 + |> case do 448 + {:ok, %{status: 200, body: body}} -> 449 + {:ok, body, dpop_nonce} 450 + 451 + {:ok, %{body: %{"error" => error, "error_description" => error_description}}} -> 452 + {:error, {:oauth_error, error, error_description}, dpop_nonce} 453 + 454 + {:ok, _} -> 455 + {:error, :unexpected_response, dpop_nonce} 456 + 457 + {:error, err} -> 458 + {:error, err, dpop_nonce} 459 + end 460 + 461 + true -> 462 + {:error, {:oauth_error, resp.body["error"], resp.body["error_description"]}, 463 + dpop_nonce} 464 + end 465 + 466 + {:error, err} -> 467 + {:error, err, nonce} 468 + end 469 + end 470 + 471 + @spec request_protected_dpop_resource( 472 + Req.Request.t(), 473 + String.t(), 474 + String.t(), 475 + JOSE.JWK.t(), 476 + String.t() | nil 477 + ) :: {:ok, Req.Response.t(), String.t() | nil} | {:error, any()} 478 + def request_protected_dpop_resource(request, issuer, access_token, dpop_key, nonce \\ nil) do 479 + access_token_hash = :crypto.hash(:sha256, access_token) |> Base.url_encode64(padding: false) 480 + # access_token_hash = Base.url_encode64(access_token, padding: false) 481 + 482 + dpop_token = 483 + create_dpop_token(dpop_key, request, nonce, %{iss: issuer, ath: access_token_hash}) 484 + 485 + request 486 + |> Req.Request.put_header("dpop", dpop_token) 487 + |> Req.request() 488 + |> case do 489 + {:ok, resp} -> 490 + dpop_nonce = 491 + case resp.headers["dpop-nonce"] do 492 + [new_nonce | _] -> new_nonce 493 + _ -> nonce 494 + end 495 + 496 + www_authenticate = Req.Response.get_header(resp, "www-authenticate") 497 + 498 + www_dpop_problem = 499 + www_authenticate != [] && String.starts_with?(Enum.at(www_authenticate, 0), "DPoP") 500 + 501 + if resp.status != 401 || !www_dpop_problem do 502 + {:ok, resp, dpop_nonce} 503 + else 504 + dpop_token = 505 + create_dpop_token(dpop_key, request, dpop_nonce, %{ 506 + iss: issuer, 507 + ath: access_token_hash 508 + }) 509 + 510 + request 511 + |> Req.Request.put_header("dpop", dpop_token) 512 + |> Req.request() 513 + |> case do 514 + {:ok, resp} -> 515 + dpop_nonce = 516 + case resp.headers["dpop-nonce"] do 517 + [new_nonce | _] -> new_nonce 518 + _ -> dpop_nonce 519 + end 520 + 521 + {:ok, resp, dpop_nonce} 522 + 523 + err -> 524 + err 525 + end 526 + end 527 + 528 + err -> 529 + err 530 + end 531 + end 532 + 533 + @spec create_client_assertion(JOSE.JWK.t(), String.t(), String.t()) :: String.t() 534 + def create_client_assertion(jwk, client_id, issuer) do 535 + iat = System.os_time(:second) 536 + jti = random_b64(20) 537 + jws = %{"alg" => "ES256", "kid" => jwk.fields["kid"]} 538 + 539 + jwt = %{ 540 + iss: client_id, 541 + sub: client_id, 542 + aud: issuer, 543 + jti: jti, 544 + iat: iat, 545 + exp: iat + 60 546 + } 547 + 548 + JOSE.JWT.sign(jwk, jws, jwt) 549 + |> JOSE.JWS.compact() 550 + |> elem(1) 551 + end 552 + 553 + @spec create_dpop_token(JOSE.JWK.t(), Req.Request.t(), any(), map()) :: String.t() 554 + def create_dpop_token(jwk, request, nonce \\ nil, attrs \\ %{}) do 555 + iat = System.os_time(:second) 556 + jti = random_b64(20) 557 + {_, public_jwk} = JOSE.JWK.to_public_map(jwk) 558 + jws = %{"alg" => "ES256", "typ" => "dpop+jwt", "jwk" => public_jwk} 559 + [request_url | _] = request.url |> to_string() |> String.split("?") 560 + 561 + jwt = 562 + Map.merge(attrs, %{ 563 + jti: jti, 564 + htm: atom_to_upcase_string(request.method), 565 + htu: request_url, 566 + iat: iat 567 + }) 568 + |> then(fn m -> 569 + if nonce, do: Map.put(m, :nonce, nonce), else: m 570 + end) 571 + 572 + JOSE.JWT.sign(jwk, jws, jwt) 573 + |> JOSE.JWS.compact() 574 + |> elem(1) 575 + end 576 + 577 + @doc false 578 + @spec atom_to_upcase_string(atom()) :: String.t() 579 + def atom_to_upcase_string(atom) do 580 + atom |> to_string() |> String.upcase() 581 + end 582 + end
+18 -74
lib/atex/xrpc/client.ex
··· 1 defmodule Atex.XRPC.Client do 2 @moduledoc """ 3 - Struct to store client information for ATProto XRPC. 4 - """ 5 6 - alias Atex.{XRPC, HTTP} 7 - use TypedStruct 8 9 - typedstruct do 10 - field :endpoint, String.t(), enforce: true 11 - field :access_token, String.t() | nil 12 - field :refresh_token, String.t() | nil 13 - end 14 - 15 - @doc """ 16 - Create a new `Atex.XRPC.Client` from an endpoint, and optionally an 17 - access/refresh token. 18 - 19 - Endpoint should be the base URL of a PDS, or an AppView in the case of 20 - unauthenticated requests (like Bluesky's public API), e.g. 21 - `https://bsky.social`. 22 """ 23 - @spec new(String.t()) :: t() 24 - @spec new(String.t(), String.t() | nil) :: t() 25 - @spec new(String.t(), String.t() | nil, String.t() | nil) :: t() 26 - def new(endpoint, access_token \\ nil, refresh_token \\ nil) do 27 - %__MODULE__{endpoint: endpoint, access_token: access_token, refresh_token: refresh_token} 28 - end 29 30 @doc """ 31 - Create a new `Atex.XRPC.Client` by logging in with an `identifier` and 32 - `password` to fetch an initial pair of access & refresh tokens. 33 34 - Uses `com.atproto.server.createSession` under the hood, so `identifier` can be 35 - either a handle or a DID. 36 - 37 - ## Examples 38 - 39 - iex> Atex.XRPC.Client.login("https://bsky.social", "example.com", "password123") 40 - {:ok, %Atex.XRPC.Client{...}} 41 """ 42 - @spec login(String.t(), String.t(), String.t()) :: {:ok, t()} | HTTP.Adapter.error() 43 - @spec login(String.t(), String.t(), String.t(), String.t() | nil) :: 44 - {:ok, t()} | HTTP.Adapter.error() 45 - def login(endpoint, identifier, password, auth_factor_token \\ nil) do 46 - json = 47 - %{identifier: identifier, password: password} 48 - |> then( 49 - &if auth_factor_token do 50 - Map.merge(&1, %{authFactorToken: auth_factor_token}) 51 - else 52 - &1 53 - end 54 - ) 55 - 56 - response = XRPC.unauthed_post(endpoint, "com.atproto.server.createSession", json: json) 57 - 58 - case response do 59 - {:ok, %{"accessJwt" => access_token, "refreshJwt" => refresh_token}} -> 60 - {:ok, new(endpoint, access_token, refresh_token)} 61 - 62 - err -> 63 - err 64 - end 65 - end 66 67 @doc """ 68 - Request a new `refresh_token` for the given client. 69 """ 70 - @spec refresh(t()) :: {:ok, t()} | HTTP.Adapter.error() 71 - def refresh(%__MODULE__{endpoint: endpoint, refresh_token: refresh_token} = client) do 72 - response = 73 - XRPC.unauthed_post( 74 - endpoint, 75 - "com.atproto.server.refreshSession", 76 - XRPC.put_auth([], refresh_token) 77 - ) 78 - 79 - case response do 80 - {:ok, %{"accessJwt" => access_token, "refreshJwt" => refresh_token}} -> 81 - %{client | access_token: access_token, refresh_token: refresh_token} 82 - 83 - err -> 84 - err 85 - end 86 - end 87 end
··· 1 defmodule Atex.XRPC.Client do 2 @moduledoc """ 3 + Behaviour that defines the interface for XRPC clients. 4 5 + This behaviour allows different types of clients (login-based, OAuth-based, etc.) 6 + to implement authentication and request handling while maintaining a consistent interface. 7 8 + Implementations must handle token refresh internally when requests fail due to 9 + expired tokens, and return both the result and potentially updated client state. 10 """ 11 + 12 + @type client :: struct() 13 + @type request_opts :: keyword() 14 + @type request_result :: {:ok, Req.Response.t(), client()} | {:error, any(), client()} 15 16 @doc """ 17 + Perform an authenticated HTTP GET request on an XRPC resource. 18 19 + Implementations should handle token refresh if the request fails due to 20 + expired authentication, returning both the response and the (potentially updated) client. 21 """ 22 + @callback get(client(), String.t(), request_opts()) :: request_result() 23 24 @doc """ 25 + Perform an authenticated HTTP POST request on an XRPC resource. 26 + 27 + Implementations should handle token refresh if the request fails due to 28 + expired authentication, returning both the response and the (potentially updated) client. 29 """ 30 + @callback post(client(), String.t(), request_opts()) :: request_result() 31 end
+148
lib/atex/xrpc/login_client.ex
···
··· 1 + defmodule Atex.XRPC.LoginClient do 2 + alias Atex.XRPC 3 + use TypedStruct 4 + 5 + @behaviour Atex.XRPC.Client 6 + 7 + typedstruct do 8 + field :endpoint, String.t(), enforce: true 9 + field :access_token, String.t() | nil 10 + field :refresh_token, String.t() | nil 11 + end 12 + 13 + @doc """ 14 + Create a new `Atex.XRPC.LoginClient` from an endpoint, and optionally an 15 + existing access/refresh token. 16 + 17 + Endpoint should be the base URL of a PDS, or an AppView in the case of 18 + unauthenticated requests (like Bluesky's public API), e.g. 19 + `https://bsky.social`. 20 + """ 21 + @spec new(String.t(), String.t() | nil, String.t() | nil) :: t() 22 + def new(endpoint, access_token \\ nil, refresh_token \\ nil) do 23 + %__MODULE__{endpoint: endpoint, access_token: access_token, refresh_token: refresh_token} 24 + end 25 + 26 + @doc """ 27 + Create a new `Atex.XRPC.LoginClient` by logging in with an `identifier` and 28 + `password` to fetch an initial pair of access & refresh tokens. 29 + 30 + Also supports providing a MFA token in the situation that is required. 31 + 32 + Uses `com.atproto.server.createSession` under the hood, so `identifier` can be 33 + either a handle or a DID. 34 + 35 + ## Examples 36 + 37 + iex> Atex.XRPC.LoginClient.login("https://bsky.social", "example.com", "password123") 38 + {:ok, %Atex.XRPC.LoginClient{...}} 39 + """ 40 + @spec login(String.t(), String.t(), String.t()) :: {:ok, t()} | {:error, any()} 41 + @spec login(String.t(), String.t(), String.t(), String.t() | nil) :: 42 + {:ok, t()} | {:error, any()} 43 + def login(endpoint, identifier, password, auth_factor_token \\ nil) do 44 + json = 45 + %{identifier: identifier, password: password} 46 + |> then( 47 + &if auth_factor_token do 48 + Map.merge(&1, %{authFactorToken: auth_factor_token}) 49 + else 50 + &1 51 + end 52 + ) 53 + 54 + response = XRPC.unauthed_post(endpoint, "com.atproto.server.createSession", json: json) 55 + 56 + case response do 57 + {:ok, %{body: %{"accessJwt" => access_token, "refreshJwt" => refresh_token}}} -> 58 + {:ok, new(endpoint, access_token, refresh_token)} 59 + 60 + err -> 61 + err 62 + end 63 + end 64 + 65 + @doc """ 66 + Request a new `refresh_token` for the given client. 67 + """ 68 + @spec refresh(t()) :: {:ok, t()} | {:error, any()} 69 + def refresh(%__MODULE__{endpoint: endpoint, refresh_token: refresh_token} = client) do 70 + request = 71 + Req.new(method: :post, url: XRPC.url(endpoint, "com.atproto.server.refreshSession")) 72 + |> put_auth(refresh_token) 73 + 74 + case Req.request(request) do 75 + {:ok, %{body: %{"accessJwt" => access_token, "refreshJwt" => refresh_token}}} -> 76 + {:ok, %{client | access_token: access_token, refresh_token: refresh_token}} 77 + 78 + {:ok, response} -> 79 + {:error, response} 80 + 81 + err -> 82 + err 83 + end 84 + end 85 + 86 + @impl true 87 + def get(%__MODULE__{} = client, resource, opts \\ []) do 88 + request(client, opts ++ [method: :get, url: XRPC.url(client.endpoint, resource)]) 89 + end 90 + 91 + @impl true 92 + def post(%__MODULE__{} = client, resource, opts \\ []) do 93 + request(client, opts ++ [method: :post, url: XRPC.url(client.endpoint, resource)]) 94 + end 95 + 96 + @spec request(t(), keyword()) :: {:ok, Req.Response.t(), t()} | {:error, any()} 97 + defp request(client, opts) do 98 + with {:ok, client} <- validate_client(client) do 99 + request = opts |> Req.new() |> put_auth(client.access_token) 100 + 101 + case Req.request(request) do 102 + {:ok, %{status: 200} = response} -> 103 + {:ok, response, client} 104 + 105 + {:ok, response} -> 106 + handle_failure(client, response, request) 107 + 108 + err -> 109 + err 110 + end 111 + end 112 + end 113 + 114 + @spec handle_failure(t(), Req.Response.t(), Req.Request.t()) :: 115 + {:ok, Req.Response.t(), t()} | {:error, any()} 116 + defp handle_failure(client, response, request) do 117 + IO.inspect(response, label: "got failure") 118 + 119 + if auth_error?(response.body) and client.refresh_token do 120 + case refresh(client) do 121 + {:ok, client} -> 122 + case Req.request(put_auth(request, client.access_token)) do 123 + {:ok, %{status: 200} = response} -> {:ok, response, client} 124 + {:ok, response} -> {:error, response} 125 + err -> err 126 + end 127 + 128 + err -> 129 + err 130 + end 131 + else 132 + {:error, response} 133 + end 134 + end 135 + 136 + @spec validate_client(t()) :: {:ok, t()} | {:error, any()} 137 + defp validate_client(%__MODULE__{access_token: nil}), do: {:error, :no_token} 138 + defp validate_client(%__MODULE__{} = client), do: {:ok, client} 139 + 140 + @spec auth_error?(body :: Req.Response.t()) :: boolean() 141 + defp auth_error?(%{status: status}) when status in [401, 403], do: true 142 + defp auth_error?(%{body: %{"error" => "InvalidToken"}}), do: true 143 + defp auth_error?(_response), do: false 144 + 145 + @spec put_auth(Req.Request.t(), String.t()) :: Req.Request.t() 146 + defp put_auth(request, token), 147 + do: Req.Request.put_header(request, "authorization", "Bearer #{token}") 148 + end
+292
lib/atex/xrpc/oauth_client.ex
···
··· 1 + defmodule Atex.XRPC.OAuthClient do 2 + @moduledoc """ 3 + OAuth client for making authenticated XRPC requests to AT Protocol servers. 4 + 5 + The client contains a user's DID and talks to `Atex.OAuth.SessionStore` to 6 + retrieve sessions internally to make requests. As a result, it will only work 7 + for users that have gone through an OAuth flow; see `Atex.OAuth.Plug` for an 8 + existing method of doing that. 9 + 10 + The entire OAuth session lifecycle is handled transparently, with the access 11 + token being refreshed automatically as required. 12 + 13 + ## Usage 14 + 15 + # Create from an existing OAuth session 16 + {:ok, client} = Atex.XRPC.OAuthClient.new("did:plc:abc123") 17 + 18 + # Or extract from a Plug.Conn after OAuth flow 19 + {:ok, client} = Atex.XRPC.OAuthClient.from_conn(conn) 20 + 21 + # Make XRPC requests 22 + {:ok, response, client} = Atex.XRPC.get(client, "com.atproto.repo.listRecords") 23 + """ 24 + 25 + alias Atex.OAuth 26 + use TypedStruct 27 + 28 + @behaviour Atex.XRPC.Client 29 + 30 + typedstruct enforce: true do 31 + field :did, String.t() 32 + end 33 + 34 + @doc """ 35 + Create a new OAuthClient from a DID. 36 + 37 + Validates that an OAuth session exists for the given DID in the session store 38 + before returning the client struct. 39 + 40 + ## Examples 41 + 42 + iex> Atex.XRPC.OAuthClient.new("did:plc:abc123") 43 + {:ok, %Atex.XRPC.OAuthClient{did: "did:plc:abc123"}} 44 + 45 + iex> Atex.XRPC.OAuthClient.new("did:plc:nosession") 46 + {:error, :not_found} 47 + 48 + """ 49 + @spec new(String.t()) :: {:ok, t()} | {:error, atom()} 50 + def new(did) do 51 + # Make sure session exists before returning a struct 52 + case Atex.OAuth.SessionStore.get(did) do 53 + {:ok, _session} -> 54 + {:ok, %__MODULE__{did: did}} 55 + 56 + err -> 57 + err 58 + end 59 + end 60 + 61 + @doc """ 62 + Create an OAuthClient from a `Plug.Conn`. 63 + 64 + Extracts the DID from the session (stored under `:atex_session` key) and validates 65 + that the OAuth session is still valid. If the token is expired or expiring soon, 66 + it attempts to refresh it. 67 + 68 + Requires the conn to have passed through `Plug.Session` and `Plug.Conn.fetch_session/2`. 69 + 70 + ## Returns 71 + 72 + - `{:ok, client}` - Successfully created client 73 + - `{:error, :reauth}` - Session exists but refresh failed, user needs to re-authenticate 74 + - `:error` - No session found in conn 75 + 76 + ## Examples 77 + 78 + # After OAuth flow completes 79 + conn = Plug.Conn.put_session(conn, :atex_session, "did:plc:abc123") 80 + {:ok, client} = Atex.XRPC.OAuthClient.from_conn(conn) 81 + 82 + """ 83 + @spec from_conn(Plug.Conn.t()) :: {:ok, t()} | :error | {:error, atom()} 84 + def from_conn(%Plug.Conn{} = conn) do 85 + oauth_did = Plug.Conn.get_session(conn, :atex_session) 86 + 87 + case oauth_did do 88 + did when is_binary(did) -> 89 + client = %__MODULE__{did: did} 90 + 91 + with_session_lock(client, fn -> 92 + case maybe_refresh(client) do 93 + {:ok, _session} -> {:ok, client} 94 + _ -> {:error, :reauth} 95 + end 96 + end) 97 + 98 + _ -> 99 + :error 100 + end 101 + end 102 + 103 + @doc """ 104 + Ask the client's OAuth server for a new set of auth tokens. 105 + 106 + Fetches the session, refreshes the tokens, creates a new session with the 107 + updated tokens, stores it, and returns the new session. 108 + 109 + You shouldn't need to call this manually for the most part, the client does 110 + its best to refresh automatically when it needs to. 111 + 112 + This function acquires a lock on the session to prevent concurrent refresh attempts. 113 + """ 114 + @spec refresh(client :: t()) :: {:ok, OAuth.Session.t()} | {:error, any()} 115 + def refresh(%__MODULE__{} = client) do 116 + with_session_lock(client, fn -> 117 + do_refresh(client) 118 + end) 119 + end 120 + 121 + @spec do_refresh(t()) :: {:ok, OAuth.Session.t()} | {:error, any()} 122 + defp do_refresh(%__MODULE__{did: did}) do 123 + with {:ok, session} <- OAuth.SessionStore.get(did), 124 + {:ok, authz_server} <- OAuth.get_authorization_server(session.aud), 125 + {:ok, %{token_endpoint: token_endpoint}} <- 126 + OAuth.get_authorization_server_metadata(authz_server) do 127 + case OAuth.refresh_token( 128 + session.refresh_token, 129 + session.dpop_key, 130 + session.iss, 131 + token_endpoint 132 + ) do 133 + {:ok, tokens, nonce} -> 134 + new_session = %OAuth.Session{ 135 + iss: session.iss, 136 + aud: session.aud, 137 + sub: tokens.did, 138 + access_token: tokens.access_token, 139 + refresh_token: tokens.refresh_token, 140 + expires_at: tokens.expires_at, 141 + dpop_key: session.dpop_key, 142 + dpop_nonce: nonce 143 + } 144 + 145 + case OAuth.SessionStore.update(new_session) do 146 + :ok -> {:ok, new_session} 147 + err -> err 148 + end 149 + 150 + err -> 151 + err 152 + end 153 + end 154 + end 155 + 156 + @spec maybe_refresh(t(), integer()) :: {:ok, OAuth.Session.t()} | {:error, any()} 157 + defp maybe_refresh(%__MODULE__{did: did} = client, buffer_minutes \\ 5) do 158 + with {:ok, session} <- OAuth.SessionStore.get(did) do 159 + if token_expiring_soon?(session.expires_at, buffer_minutes) do 160 + do_refresh(client) 161 + else 162 + {:ok, session} 163 + end 164 + end 165 + end 166 + 167 + @spec token_expiring_soon?(NaiveDateTime.t(), integer()) :: boolean() 168 + defp token_expiring_soon?(expires_at, buffer_minutes) do 169 + now = NaiveDateTime.utc_now() 170 + expiry_threshold = NaiveDateTime.add(now, buffer_minutes * 60, :second) 171 + 172 + NaiveDateTime.compare(expires_at, expiry_threshold) in [:lt, :eq] 173 + end 174 + 175 + @doc """ 176 + Make a GET request to an XRPC endpoint. 177 + 178 + See `Atex.XRPC.get/3` for details. 179 + """ 180 + @impl true 181 + def get(%__MODULE__{} = client, resource, opts \\ []) do 182 + # TODO: Keyword.valiate to make sure :method isn't passed? 183 + request(client, resource, opts ++ [method: :get]) 184 + end 185 + 186 + @doc """ 187 + Make a POST request to an XRPC endpoint. 188 + 189 + See `Atex.XRPC.post/3` for details. 190 + """ 191 + @impl true 192 + def post(%__MODULE__{} = client, resource, opts \\ []) do 193 + # Ditto 194 + request(client, resource, opts ++ [method: :post]) 195 + end 196 + 197 + defp request(%__MODULE__{} = client, resource, opts) do 198 + with_session_lock(client, fn -> 199 + case maybe_refresh(client) do 200 + {:ok, session} -> 201 + url = Atex.XRPC.url(session.aud, resource) 202 + 203 + request = 204 + opts 205 + |> Keyword.put(:url, url) 206 + |> Req.new() 207 + |> Req.Request.put_header("authorization", "DPoP #{session.access_token}") 208 + 209 + case OAuth.request_protected_dpop_resource( 210 + request, 211 + session.iss, 212 + session.access_token, 213 + session.dpop_key, 214 + session.dpop_nonce 215 + ) do 216 + {:ok, %{status: 200} = response, nonce} -> 217 + update_session_nonce(session, nonce) 218 + {:ok, response, client} 219 + 220 + {:ok, response, nonce} -> 221 + update_session_nonce(session, nonce) 222 + handle_failure(client, request, response) 223 + 224 + err -> 225 + err 226 + end 227 + 228 + err -> 229 + err 230 + end 231 + end) 232 + end 233 + 234 + # Execute a function with an exclusive lock on the session identified by the 235 + # client's DID. This ensures that concurrent requests for the same user don't 236 + # race during token refresh. 237 + @spec with_session_lock(t(), (-> result)) :: result when result: any() 238 + defp with_session_lock(%__MODULE__{did: did}, fun) do 239 + Mutex.with_lock(Atex.SessionMutex, did, fun) 240 + end 241 + 242 + defp handle_failure(client, request, response) do 243 + if auth_error?(response) do 244 + case do_refresh(client) do 245 + {:ok, session} -> 246 + case OAuth.request_protected_dpop_resource( 247 + request, 248 + session.iss, 249 + session.access_token, 250 + session.dpop_key, 251 + session.dpop_nonce 252 + ) do 253 + {:ok, %{status: 200} = response, nonce} -> 254 + update_session_nonce(session, nonce) 255 + {:ok, response, client} 256 + 257 + {:ok, response, _nonce} -> 258 + if auth_error?(response) do 259 + # We tried to refresh the token once but it's still failing 260 + # Clear session and prompt dev to reauth or something 261 + OAuth.SessionStore.delete(session) 262 + {:error, response, :expired} 263 + else 264 + {:error, response, client} 265 + end 266 + 267 + err -> 268 + err 269 + end 270 + 271 + err -> 272 + err 273 + end 274 + else 275 + {:error, response, client} 276 + end 277 + end 278 + 279 + @spec auth_error?(Req.Response.t()) :: boolean() 280 + defp auth_error?(%{status: 401, headers: %{"www-authenticate" => [www_auth]}}), 281 + do: 282 + (String.starts_with?(www_auth, "Bearer") or String.starts_with?(www_auth, "DPoP")) and 283 + String.contains?(www_auth, "error=\"invalid_token\"") 284 + 285 + defp auth_error?(_resp), do: false 286 + 287 + defp update_session_nonce(session, nonce) do 288 + session = %{session | dpop_nonce: nonce} 289 + :ok = OAuth.SessionStore.update(session) 290 + session 291 + end 292 + end
+181 -38
lib/atex/xrpc.ex
··· 1 defmodule Atex.XRPC do 2 - alias Atex.{HTTP, XRPC} 3 4 - # TODO: automatic user-agent, and env for changing it 5 6 - # TODO: consistent struct shape/protocol for Lexicon schemas so that user can pass in 7 - # an object (hopefully validated by its module) without needing to specify the 8 - # name & opts separately, and possibly verify the output response against it? 9 10 - # TODO: auto refresh, will need to return a client instance in each method. 11 12 @doc """ 13 Perform a HTTP GET on a XRPC resource. Called a "query" in lexicons. 14 """ 15 - @spec get(XRPC.Client.t(), String.t(), keyword()) :: HTTP.Adapter.result() 16 - def get(%XRPC.Client{} = client, name, opts \\ []) do 17 - opts = put_auth(opts, client.access_token) 18 - HTTP.get(url(client, name), opts) 19 end 20 21 @doc """ 22 - Perform a HTTP POST on a XRPC resource. Called a "prodecure" in lexicons. 23 """ 24 - @spec post(XRPC.Client.t(), String.t(), keyword()) :: HTTP.Adapter.result() 25 - def post(%XRPC.Client{} = client, name, opts \\ []) do 26 - # TODO: look through available HTTP clients and see if they have a 27 - # consistent way of providing JSON bodies with auto content-type. If not, 28 - # create one for adapters. 29 - opts = put_auth(opts, client.access_token) 30 - HTTP.post(url(client, name), opts) 31 end 32 33 @doc """ 34 Like `get/3` but is unauthenticated by default. 35 """ 36 - @spec unauthed_get(String.t(), String.t(), keyword()) :: HTTP.Adapter.result() 37 def unauthed_get(endpoint, name, opts \\ []) do 38 - HTTP.get(url(endpoint, name), opts) 39 end 40 41 @doc """ 42 Like `post/3` but is unauthenticated by default. 43 """ 44 - @spec unauthed_post(String.t(), String.t(), keyword()) :: HTTP.Adapter.result() 45 def unauthed_post(endpoint, name, opts \\ []) do 46 - HTTP.post(url(endpoint, name), opts) 47 end 48 49 - # TODO: use URI module for joining instead? 50 - @spec url(XRPC.Client.t() | String.t(), String.t()) :: String.t() 51 - defp url(%XRPC.Client{endpoint: endpoint}, name), do: url(endpoint, name) 52 - defp url(endpoint, name) when is_binary(endpoint), do: "#{endpoint}/xrpc/#{name}" 53 54 - @doc """ 55 - Put an `authorization` header into a keyword list of options to pass to a HTTP client. 56 """ 57 - @spec put_auth(keyword(), String.t()) :: keyword() 58 - def put_auth(opts, token), 59 - do: put_headers(opts, authorization: "Bearer #{token}") 60 - 61 - @spec put_headers(keyword(), keyword()) :: keyword() 62 - defp put_headers(opts, headers) do 63 - opts 64 - |> Keyword.put_new(:headers, []) 65 - |> Keyword.update(:headers, [], &Keyword.merge(&1, headers)) 66 - end 67 end
··· 1 defmodule Atex.XRPC do 2 + @moduledoc """ 3 + XRPC client module for AT Protocol RPC calls. 4 5 + This module provides both authenticated and unauthenticated access to AT Protocol 6 + XRPC endpoints. The authenticated functions (`get/3`, `post/3`) work with any 7 + client that implements the `Atex.XRPC.Client`. 8 9 + ## Example usage 10 11 + # Login-based client 12 + {:ok, client} = Atex.XRPC.LoginClient.login("https://bsky.social", "user.bsky.social", "password") 13 + {:ok, response, client} = Atex.XRPC.get(client, "app.bsky.actor.getProfile", params: [actor: "user.bsky.social"]) 14 + 15 + # OAuth-based client 16 + {:ok, oauth_client} = Atex.XRPC.OAuthClient.from_conn(conn) 17 + {:ok, response, oauth_client} = Atex.XRPC.get(oauth_client, "app.bsky.actor.getProfile", params: [actor: "user.bsky.social"]) 18 + 19 + ## Unauthenticated requests 20 + 21 + Unauthenticated functions (`unauthed_get/3`, `unauthed_post/3`) do not require a client 22 + and work directly with endpoints: 23 + 24 + {:ok, response} = Atex.XRPC.unauthed_get("https://bsky.social", "com.atproto.sync.getHead", params: [did: "did:plc:..."]) 25 + """ 26 + 27 + alias Atex.XRPC.Client 28 29 @doc """ 30 Perform a HTTP GET on a XRPC resource. Called a "query" in lexicons. 31 + 32 + Accepts any client that implements `Atex.XRPC.Client` and returns 33 + both the response and the (potentially updated) client. 34 + 35 + Can be called either with the XRPC operation name as a string, or with a lexicon 36 + struct (generated via `deflexicon`) for type safety and automatic parameter/response handling. 37 + 38 + When using a lexicon struct, the response body will be automatically converted to the 39 + corresponding type if an Output struct exists for the lexicon. 40 + 41 + ## Examples 42 + 43 + # Using string XRPC name 44 + {:ok, response, client} = 45 + Atex.XRPC.get(client, "app.bsky.actor.getProfile", params: [actor: "ovyerus.com"]) 46 + 47 + # Using lexicon struct with typed construction 48 + {:ok, response, client} = 49 + Atex.XRPC.get(client, %App.Bsky.Actor.GetProfile{ 50 + params: %App.Bsky.Actor.GetProfile.Params{actor: "ovyerus.com"} 51 + }) 52 """ 53 + @spec get(Client.client(), String.t() | struct(), keyword()) :: 54 + {:ok, Req.Response.t(), Client.client()} 55 + | {:error, any(), Client.client()} 56 + def get(client, name, opts \\ []) 57 + 58 + def get(client, name, opts) when is_binary(name) do 59 + client.__struct__.get(client, name, opts) 60 + end 61 + 62 + def get(client, %{__struct__: module} = query, opts) do 63 + opts = if Map.get(query, :params), do: Keyword.put(opts, :params, query.params), else: opts 64 + output_struct = Module.concat(module, Output) 65 + output_exists = Code.ensure_loaded?(output_struct) 66 + 67 + case client.__struct__.get(client, module.id(), opts) do 68 + {:ok, %{status: 200} = response, client} -> 69 + if output_exists do 70 + case output_struct.from_json(response.body) do 71 + {:ok, output} -> 72 + {:ok, %{response | body: output}, client} 73 + 74 + err -> 75 + err 76 + end 77 + else 78 + {:ok, response, client} 79 + end 80 + 81 + {:ok, _, _} = ok -> 82 + ok 83 + 84 + err -> 85 + err 86 + end 87 end 88 89 @doc """ 90 + Perform a HTTP POST on a XRPC resource. Called a "procedure" in lexicons. 91 + 92 + Accepts any client that implements `Atex.XRPC.Client` and returns both the 93 + response and the (potentially updated) client. 94 + 95 + Can be called either with the XRPC operation name as a string, or with a 96 + lexicon struct (generated via `deflexicon`) for type safety and automatic 97 + input/parameter mapping. 98 + 99 + When using a lexicon struct, the response body will be automatically converted 100 + to the corresponding type if an Output struct exists for the lexicon. 101 + 102 + ## Examples 103 + 104 + # Using string XRPC name 105 + {:ok, response, client} = 106 + Atex.XRPC.post( 107 + client, 108 + "com.atproto.repo.createRecord", 109 + json: %{ 110 + repo: "did:plc:...", 111 + collection: "app.bsky.feed.post", 112 + rkey: Atex.TID.now() |> to_string(), 113 + record: %{ 114 + text: "Hello World", 115 + createdAt: DateTime.to_iso8601(DateTime.utc_now()) 116 + } 117 + } 118 + ) 119 + 120 + # Using lexicon struct with typed construction 121 + {:ok, response, client} = 122 + Atex.XRPC.post(client, %Com.Atproto.Repo.CreateRecord{ 123 + input: %Com.Atproto.Repo.CreateRecord.Input{ 124 + repo: "did:plc:...", 125 + collection: "app.bsky.feed.post", 126 + rkey: Atex.TID.now() |> to_string(), 127 + record: %App.Bsky.Feed.Post{ 128 + text: "Hello World!", 129 + createdAt: DateTime.to_iso8601(DateTime.utc_now()) 130 + } 131 + } 132 + }) 133 """ 134 + @spec post(Client.client(), String.t() | struct(), keyword()) :: 135 + {:ok, Req.Response.t(), Client.client()} 136 + | {:error, any(), Client.client()} 137 + def post(client, name, opts \\ []) 138 + 139 + def post(client, name, opts) when is_binary(name) do 140 + client.__struct__.post(client, name, opts) 141 + end 142 + 143 + def post(client, %{__struct__: module} = procedure, opts) do 144 + opts = 145 + opts 146 + |> then( 147 + &if Map.get(procedure, :params), do: Keyword.put(&1, :params, procedure.params), else: &1 148 + ) 149 + |> then( 150 + &cond do 151 + Map.get(procedure, :input) -> Keyword.put(&1, :json, procedure.input) 152 + Map.get(procedure, :raw_input) -> Keyword.put(&1, :body, procedure.raw_input) 153 + true -> &1 154 + end 155 + ) 156 + 157 + output_struct = Module.concat(module, Output) 158 + output_exists = Code.ensure_loaded?(output_struct) 159 + 160 + case client.__struct__.post(client, module.id(), opts) do 161 + {:ok, %{status: 200} = response, client} -> 162 + if output_exists do 163 + case output_struct.from_json(response.body) do 164 + {:ok, output} -> 165 + {:ok, %{response | body: output}, client} 166 + 167 + err -> 168 + err 169 + end 170 + else 171 + {:ok, response, client} 172 + end 173 + 174 + {:ok, _, _} = ok -> 175 + ok 176 + 177 + err -> 178 + err 179 + end 180 end 181 182 @doc """ 183 Like `get/3` but is unauthenticated by default. 184 """ 185 + @spec unauthed_get(String.t(), String.t(), keyword()) :: 186 + {:ok, Req.Response.t()} | {:error, any()} 187 def unauthed_get(endpoint, name, opts \\ []) do 188 + Req.get(url(endpoint, name), opts) 189 end 190 191 @doc """ 192 Like `post/3` but is unauthenticated by default. 193 """ 194 + @spec unauthed_post(String.t(), String.t(), keyword()) :: 195 + {:ok, Req.Response.t()} | {:error, any()} 196 def unauthed_post(endpoint, name, opts \\ []) do 197 + Req.post(url(endpoint, name), opts) 198 end 199 200 + @doc """ 201 + Create an XRPC url based on an endpoint and a resource name. 202 + 203 + ## Example 204 205 + iex> Atex.XRPC.url("https://bsky.app", "app.bsky.actor.getProfile") 206 + "https://bsky.app/xrpc/app.bsky.actor.getProfile" 207 """ 208 + @spec url(String.t(), String.t()) :: String.t() 209 + def url(endpoint, resource) when is_binary(endpoint), do: "#{endpoint}/xrpc/#{resource}" 210 end
+74
lib/atproto/com/atproto/admin/defs.ex
···
··· 1 + defmodule Com.Atproto.Admin.Defs do 2 + @moduledoc false 3 + use Atex.Lexicon 4 + 5 + deflexicon(%{ 6 + "defs" => %{ 7 + "accountView" => %{ 8 + "properties" => %{ 9 + "deactivatedAt" => %{"format" => "datetime", "type" => "string"}, 10 + "did" => %{"format" => "did", "type" => "string"}, 11 + "email" => %{"type" => "string"}, 12 + "emailConfirmedAt" => %{"format" => "datetime", "type" => "string"}, 13 + "handle" => %{"format" => "handle", "type" => "string"}, 14 + "indexedAt" => %{"format" => "datetime", "type" => "string"}, 15 + "inviteNote" => %{"type" => "string"}, 16 + "invitedBy" => %{ 17 + "ref" => "com.atproto.server.defs#inviteCode", 18 + "type" => "ref" 19 + }, 20 + "invites" => %{ 21 + "items" => %{ 22 + "ref" => "com.atproto.server.defs#inviteCode", 23 + "type" => "ref" 24 + }, 25 + "type" => "array" 26 + }, 27 + "invitesDisabled" => %{"type" => "boolean"}, 28 + "relatedRecords" => %{ 29 + "items" => %{"type" => "unknown"}, 30 + "type" => "array" 31 + }, 32 + "threatSignatures" => %{ 33 + "items" => %{"ref" => "#threatSignature", "type" => "ref"}, 34 + "type" => "array" 35 + } 36 + }, 37 + "required" => ["did", "handle", "indexedAt"], 38 + "type" => "object" 39 + }, 40 + "repoBlobRef" => %{ 41 + "properties" => %{ 42 + "cid" => %{"format" => "cid", "type" => "string"}, 43 + "did" => %{"format" => "did", "type" => "string"}, 44 + "recordUri" => %{"format" => "at-uri", "type" => "string"} 45 + }, 46 + "required" => ["did", "cid"], 47 + "type" => "object" 48 + }, 49 + "repoRef" => %{ 50 + "properties" => %{"did" => %{"format" => "did", "type" => "string"}}, 51 + "required" => ["did"], 52 + "type" => "object" 53 + }, 54 + "statusAttr" => %{ 55 + "properties" => %{ 56 + "applied" => %{"type" => "boolean"}, 57 + "ref" => %{"type" => "string"} 58 + }, 59 + "required" => ["applied"], 60 + "type" => "object" 61 + }, 62 + "threatSignature" => %{ 63 + "properties" => %{ 64 + "property" => %{"type" => "string"}, 65 + "value" => %{"type" => "string"} 66 + }, 67 + "required" => ["property", "value"], 68 + "type" => "object" 69 + } 70 + }, 71 + "id" => "com.atproto.admin.defs", 72 + "lexicon" => 1 73 + }) 74 + end
+23
lib/atproto/com/atproto/admin/deleteAccount.ex
···
··· 1 + defmodule Com.Atproto.Admin.DeleteAccount do 2 + @moduledoc false 3 + use Atex.Lexicon 4 + 5 + deflexicon(%{ 6 + "defs" => %{ 7 + "main" => %{ 8 + "description" => "Delete a user account as an administrator.", 9 + "input" => %{ 10 + "encoding" => "application/json", 11 + "schema" => %{ 12 + "properties" => %{"did" => %{"format" => "did", "type" => "string"}}, 13 + "required" => ["did"], 14 + "type" => "object" 15 + } 16 + }, 17 + "type" => "procedure" 18 + } 19 + }, 20 + "id" => "com.atproto.admin.deleteAccount", 21 + "lexicon" => 1 22 + }) 23 + end
+30
lib/atproto/com/atproto/admin/disableAccountInvites.ex
···
··· 1 + defmodule Com.Atproto.Admin.DisableAccountInvites do 2 + @moduledoc false 3 + use Atex.Lexicon 4 + 5 + deflexicon(%{ 6 + "defs" => %{ 7 + "main" => %{ 8 + "description" => 9 + "Disable an account from receiving new invite codes, but does not invalidate existing codes.", 10 + "input" => %{ 11 + "encoding" => "application/json", 12 + "schema" => %{ 13 + "properties" => %{ 14 + "account" => %{"format" => "did", "type" => "string"}, 15 + "note" => %{ 16 + "description" => "Optional reason for disabled invites.", 17 + "type" => "string" 18 + } 19 + }, 20 + "required" => ["account"], 21 + "type" => "object" 22 + } 23 + }, 24 + "type" => "procedure" 25 + } 26 + }, 27 + "id" => "com.atproto.admin.disableAccountInvites", 28 + "lexicon" => 1 29 + }) 30 + end
+26
lib/atproto/com/atproto/admin/disableInviteCodes.ex
···
··· 1 + defmodule Com.Atproto.Admin.DisableInviteCodes do 2 + @moduledoc false 3 + use Atex.Lexicon 4 + 5 + deflexicon(%{ 6 + "defs" => %{ 7 + "main" => %{ 8 + "description" => 9 + "Disable some set of codes and/or all codes associated with a set of users.", 10 + "input" => %{ 11 + "encoding" => "application/json", 12 + "schema" => %{ 13 + "properties" => %{ 14 + "accounts" => %{"items" => %{"type" => "string"}, "type" => "array"}, 15 + "codes" => %{"items" => %{"type" => "string"}, "type" => "array"} 16 + }, 17 + "type" => "object" 18 + } 19 + }, 20 + "type" => "procedure" 21 + } 22 + }, 23 + "id" => "com.atproto.admin.disableInviteCodes", 24 + "lexicon" => 1 25 + }) 26 + end
+29
lib/atproto/com/atproto/admin/enableAccountInvites.ex
···
··· 1 + defmodule Com.Atproto.Admin.EnableAccountInvites do 2 + @moduledoc false 3 + use Atex.Lexicon 4 + 5 + deflexicon(%{ 6 + "defs" => %{ 7 + "main" => %{ 8 + "description" => "Re-enable an account's ability to receive invite codes.", 9 + "input" => %{ 10 + "encoding" => "application/json", 11 + "schema" => %{ 12 + "properties" => %{ 13 + "account" => %{"format" => "did", "type" => "string"}, 14 + "note" => %{ 15 + "description" => "Optional reason for enabled invites.", 16 + "type" => "string" 17 + } 18 + }, 19 + "required" => ["account"], 20 + "type" => "object" 21 + } 22 + }, 23 + "type" => "procedure" 24 + } 25 + }, 26 + "id" => "com.atproto.admin.enableAccountInvites", 27 + "lexicon" => 1 28 + }) 29 + end
+27
lib/atproto/com/atproto/admin/getAccountInfo.ex
···
··· 1 + defmodule Com.Atproto.Admin.GetAccountInfo do 2 + @moduledoc false 3 + use Atex.Lexicon 4 + 5 + deflexicon(%{ 6 + "defs" => %{ 7 + "main" => %{ 8 + "description" => "Get details about an account.", 9 + "output" => %{ 10 + "encoding" => "application/json", 11 + "schema" => %{ 12 + "ref" => "com.atproto.admin.defs#accountView", 13 + "type" => "ref" 14 + } 15 + }, 16 + "parameters" => %{ 17 + "properties" => %{"did" => %{"format" => "did", "type" => "string"}}, 18 + "required" => ["did"], 19 + "type" => "params" 20 + }, 21 + "type" => "query" 22 + } 23 + }, 24 + "id" => "com.atproto.admin.getAccountInfo", 25 + "lexicon" => 1 26 + }) 27 + end
+41
lib/atproto/com/atproto/admin/getAccountInfos.ex
···
··· 1 + defmodule Com.Atproto.Admin.GetAccountInfos do 2 + @moduledoc false 3 + use Atex.Lexicon 4 + 5 + deflexicon(%{ 6 + "defs" => %{ 7 + "main" => %{ 8 + "description" => "Get details about some accounts.", 9 + "output" => %{ 10 + "encoding" => "application/json", 11 + "schema" => %{ 12 + "properties" => %{ 13 + "infos" => %{ 14 + "items" => %{ 15 + "ref" => "com.atproto.admin.defs#accountView", 16 + "type" => "ref" 17 + }, 18 + "type" => "array" 19 + } 20 + }, 21 + "required" => ["infos"], 22 + "type" => "object" 23 + } 24 + }, 25 + "parameters" => %{ 26 + "properties" => %{ 27 + "dids" => %{ 28 + "items" => %{"format" => "did", "type" => "string"}, 29 + "type" => "array" 30 + } 31 + }, 32 + "required" => ["dids"], 33 + "type" => "params" 34 + }, 35 + "type" => "query" 36 + } 37 + }, 38 + "id" => "com.atproto.admin.getAccountInfos", 39 + "lexicon" => 1 40 + }) 41 + end
+49
lib/atproto/com/atproto/admin/getInviteCodes.ex
···
··· 1 + defmodule Com.Atproto.Admin.GetInviteCodes do 2 + @moduledoc false 3 + use Atex.Lexicon 4 + 5 + deflexicon(%{ 6 + "defs" => %{ 7 + "main" => %{ 8 + "description" => "Get an admin view of invite codes.", 9 + "output" => %{ 10 + "encoding" => "application/json", 11 + "schema" => %{ 12 + "properties" => %{ 13 + "codes" => %{ 14 + "items" => %{ 15 + "ref" => "com.atproto.server.defs#inviteCode", 16 + "type" => "ref" 17 + }, 18 + "type" => "array" 19 + }, 20 + "cursor" => %{"type" => "string"} 21 + }, 22 + "required" => ["codes"], 23 + "type" => "object" 24 + } 25 + }, 26 + "parameters" => %{ 27 + "properties" => %{ 28 + "cursor" => %{"type" => "string"}, 29 + "limit" => %{ 30 + "default" => 100, 31 + "maximum" => 500, 32 + "minimum" => 1, 33 + "type" => "integer" 34 + }, 35 + "sort" => %{ 36 + "default" => "recent", 37 + "knownValues" => ["recent", "usage"], 38 + "type" => "string" 39 + } 40 + }, 41 + "type" => "params" 42 + }, 43 + "type" => "query" 44 + } 45 + }, 46 + "id" => "com.atproto.admin.getInviteCodes", 47 + "lexicon" => 1 48 + }) 49 + end
+49
lib/atproto/com/atproto/admin/getSubjectStatus.ex
···
··· 1 + defmodule Com.Atproto.Admin.GetSubjectStatus do 2 + @moduledoc false 3 + use Atex.Lexicon 4 + 5 + deflexicon(%{ 6 + "defs" => %{ 7 + "main" => %{ 8 + "description" => 9 + "Get the service-specific admin status of a subject (account, record, or blob).", 10 + "output" => %{ 11 + "encoding" => "application/json", 12 + "schema" => %{ 13 + "properties" => %{ 14 + "deactivated" => %{ 15 + "ref" => "com.atproto.admin.defs#statusAttr", 16 + "type" => "ref" 17 + }, 18 + "subject" => %{ 19 + "refs" => [ 20 + "com.atproto.admin.defs#repoRef", 21 + "com.atproto.repo.strongRef", 22 + "com.atproto.admin.defs#repoBlobRef" 23 + ], 24 + "type" => "union" 25 + }, 26 + "takedown" => %{ 27 + "ref" => "com.atproto.admin.defs#statusAttr", 28 + "type" => "ref" 29 + } 30 + }, 31 + "required" => ["subject"], 32 + "type" => "object" 33 + } 34 + }, 35 + "parameters" => %{ 36 + "properties" => %{ 37 + "blob" => %{"format" => "cid", "type" => "string"}, 38 + "did" => %{"format" => "did", "type" => "string"}, 39 + "uri" => %{"format" => "at-uri", "type" => "string"} 40 + }, 41 + "type" => "params" 42 + }, 43 + "type" => "query" 44 + } 45 + }, 46 + "id" => "com.atproto.admin.getSubjectStatus", 47 + "lexicon" => 1 48 + }) 49 + end
+45
lib/atproto/com/atproto/admin/searchAccounts.ex
···
··· 1 + defmodule Com.Atproto.Admin.SearchAccounts do 2 + @moduledoc false 3 + use Atex.Lexicon 4 + 5 + deflexicon(%{ 6 + "defs" => %{ 7 + "main" => %{ 8 + "description" => "Get list of accounts that matches your search query.", 9 + "output" => %{ 10 + "encoding" => "application/json", 11 + "schema" => %{ 12 + "properties" => %{ 13 + "accounts" => %{ 14 + "items" => %{ 15 + "ref" => "com.atproto.admin.defs#accountView", 16 + "type" => "ref" 17 + }, 18 + "type" => "array" 19 + }, 20 + "cursor" => %{"type" => "string"} 21 + }, 22 + "required" => ["accounts"], 23 + "type" => "object" 24 + } 25 + }, 26 + "parameters" => %{ 27 + "properties" => %{ 28 + "cursor" => %{"type" => "string"}, 29 + "email" => %{"type" => "string"}, 30 + "limit" => %{ 31 + "default" => 50, 32 + "maximum" => 100, 33 + "minimum" => 1, 34 + "type" => "integer" 35 + } 36 + }, 37 + "type" => "params" 38 + }, 39 + "type" => "query" 40 + } 41 + }, 42 + "id" => "com.atproto.admin.searchAccounts", 43 + "lexicon" => 1 44 + }) 45 + end
+41
lib/atproto/com/atproto/admin/sendEmail.ex
···
··· 1 + defmodule Com.Atproto.Admin.SendEmail do 2 + @moduledoc false 3 + use Atex.Lexicon 4 + 5 + deflexicon(%{ 6 + "defs" => %{ 7 + "main" => %{ 8 + "description" => "Send email to a user's account email address.", 9 + "input" => %{ 10 + "encoding" => "application/json", 11 + "schema" => %{ 12 + "properties" => %{ 13 + "comment" => %{ 14 + "description" => 15 + "Additional comment by the sender that won't be used in the email itself but helpful to provide more context for moderators/reviewers", 16 + "type" => "string" 17 + }, 18 + "content" => %{"type" => "string"}, 19 + "recipientDid" => %{"format" => "did", "type" => "string"}, 20 + "senderDid" => %{"format" => "did", "type" => "string"}, 21 + "subject" => %{"type" => "string"} 22 + }, 23 + "required" => ["recipientDid", "content", "senderDid"], 24 + "type" => "object" 25 + } 26 + }, 27 + "output" => %{ 28 + "encoding" => "application/json", 29 + "schema" => %{ 30 + "properties" => %{"sent" => %{"type" => "boolean"}}, 31 + "required" => ["sent"], 32 + "type" => "object" 33 + } 34 + }, 35 + "type" => "procedure" 36 + } 37 + }, 38 + "id" => "com.atproto.admin.sendEmail", 39 + "lexicon" => 1 40 + }) 41 + end
+30
lib/atproto/com/atproto/admin/updateAccountEmail.ex
···
··· 1 + defmodule Com.Atproto.Admin.UpdateAccountEmail do 2 + @moduledoc false 3 + use Atex.Lexicon 4 + 5 + deflexicon(%{ 6 + "defs" => %{ 7 + "main" => %{ 8 + "description" => "Administrative action to update an account's email.", 9 + "input" => %{ 10 + "encoding" => "application/json", 11 + "schema" => %{ 12 + "properties" => %{ 13 + "account" => %{ 14 + "description" => "The handle or DID of the repo.", 15 + "format" => "at-identifier", 16 + "type" => "string" 17 + }, 18 + "email" => %{"type" => "string"} 19 + }, 20 + "required" => ["account", "email"], 21 + "type" => "object" 22 + } 23 + }, 24 + "type" => "procedure" 25 + } 26 + }, 27 + "id" => "com.atproto.admin.updateAccountEmail", 28 + "lexicon" => 1 29 + }) 30 + end
+26
lib/atproto/com/atproto/admin/updateAccountHandle.ex
···
··· 1 + defmodule Com.Atproto.Admin.UpdateAccountHandle do 2 + @moduledoc false 3 + use Atex.Lexicon 4 + 5 + deflexicon(%{ 6 + "defs" => %{ 7 + "main" => %{ 8 + "description" => "Administrative action to update an account's handle.", 9 + "input" => %{ 10 + "encoding" => "application/json", 11 + "schema" => %{ 12 + "properties" => %{ 13 + "did" => %{"format" => "did", "type" => "string"}, 14 + "handle" => %{"format" => "handle", "type" => "string"} 15 + }, 16 + "required" => ["did", "handle"], 17 + "type" => "object" 18 + } 19 + }, 20 + "type" => "procedure" 21 + } 22 + }, 23 + "id" => "com.atproto.admin.updateAccountHandle", 24 + "lexicon" => 1 25 + }) 26 + end
+26
lib/atproto/com/atproto/admin/updateAccountPassword.ex
···
··· 1 + defmodule Com.Atproto.Admin.UpdateAccountPassword do 2 + @moduledoc false 3 + use Atex.Lexicon 4 + 5 + deflexicon(%{ 6 + "defs" => %{ 7 + "main" => %{ 8 + "description" => "Update the password for a user account as an administrator.", 9 + "input" => %{ 10 + "encoding" => "application/json", 11 + "schema" => %{ 12 + "properties" => %{ 13 + "did" => %{"format" => "did", "type" => "string"}, 14 + "password" => %{"type" => "string"} 15 + }, 16 + "required" => ["did", "password"], 17 + "type" => "object" 18 + } 19 + }, 20 + "type" => "procedure" 21 + } 22 + }, 23 + "id" => "com.atproto.admin.updateAccountPassword", 24 + "lexicon" => 1 25 + }) 26 + end
+31
lib/atproto/com/atproto/admin/updateAccountSigningKey.ex
···
··· 1 + defmodule Com.Atproto.Admin.UpdateAccountSigningKey do 2 + @moduledoc false 3 + use Atex.Lexicon 4 + 5 + deflexicon(%{ 6 + "defs" => %{ 7 + "main" => %{ 8 + "description" => 9 + "Administrative action to update an account's signing key in their Did document.", 10 + "input" => %{ 11 + "encoding" => "application/json", 12 + "schema" => %{ 13 + "properties" => %{ 14 + "did" => %{"format" => "did", "type" => "string"}, 15 + "signingKey" => %{ 16 + "description" => "Did-key formatted public key", 17 + "format" => "did", 18 + "type" => "string" 19 + } 20 + }, 21 + "required" => ["did", "signingKey"], 22 + "type" => "object" 23 + } 24 + }, 25 + "type" => "procedure" 26 + } 27 + }, 28 + "id" => "com.atproto.admin.updateAccountSigningKey", 29 + "lexicon" => 1 30 + }) 31 + end
+62
lib/atproto/com/atproto/admin/updateSubjectStatus.ex
···
··· 1 + defmodule Com.Atproto.Admin.UpdateSubjectStatus do 2 + @moduledoc false 3 + use Atex.Lexicon 4 + 5 + deflexicon(%{ 6 + "defs" => %{ 7 + "main" => %{ 8 + "description" => 9 + "Update the service-specific admin status of a subject (account, record, or blob).", 10 + "input" => %{ 11 + "encoding" => "application/json", 12 + "schema" => %{ 13 + "properties" => %{ 14 + "deactivated" => %{ 15 + "ref" => "com.atproto.admin.defs#statusAttr", 16 + "type" => "ref" 17 + }, 18 + "subject" => %{ 19 + "refs" => [ 20 + "com.atproto.admin.defs#repoRef", 21 + "com.atproto.repo.strongRef", 22 + "com.atproto.admin.defs#repoBlobRef" 23 + ], 24 + "type" => "union" 25 + }, 26 + "takedown" => %{ 27 + "ref" => "com.atproto.admin.defs#statusAttr", 28 + "type" => "ref" 29 + } 30 + }, 31 + "required" => ["subject"], 32 + "type" => "object" 33 + } 34 + }, 35 + "output" => %{ 36 + "encoding" => "application/json", 37 + "schema" => %{ 38 + "properties" => %{ 39 + "subject" => %{ 40 + "refs" => [ 41 + "com.atproto.admin.defs#repoRef", 42 + "com.atproto.repo.strongRef", 43 + "com.atproto.admin.defs#repoBlobRef" 44 + ], 45 + "type" => "union" 46 + }, 47 + "takedown" => %{ 48 + "ref" => "com.atproto.admin.defs#statusAttr", 49 + "type" => "ref" 50 + } 51 + }, 52 + "required" => ["subject"], 53 + "type" => "object" 54 + } 55 + }, 56 + "type" => "procedure" 57 + } 58 + }, 59 + "id" => "com.atproto.admin.updateSubjectStatus", 60 + "lexicon" => 1 61 + }) 62 + end
+28
lib/atproto/com/atproto/identity/defs.ex
···
··· 1 + defmodule Com.Atproto.Identity.Defs do 2 + @moduledoc false 3 + use Atex.Lexicon 4 + 5 + deflexicon(%{ 6 + "defs" => %{ 7 + "identityInfo" => %{ 8 + "properties" => %{ 9 + "did" => %{"format" => "did", "type" => "string"}, 10 + "didDoc" => %{ 11 + "description" => "The complete DID document for the identity.", 12 + "type" => "unknown" 13 + }, 14 + "handle" => %{ 15 + "description" => 16 + "The validated handle of the account; or 'handle.invalid' if the handle did not bi-directionally match the DID document.", 17 + "format" => "handle", 18 + "type" => "string" 19 + } 20 + }, 21 + "required" => ["did", "handle", "didDoc"], 22 + "type" => "object" 23 + } 24 + }, 25 + "id" => "com.atproto.identity.defs", 26 + "lexicon" => 1 27 + }) 28 + end
+36
lib/atproto/com/atproto/identity/getRecommendedDidCredentials.ex
···
··· 1 + defmodule Com.Atproto.Identity.GetRecommendedDidCredentials do 2 + @moduledoc false 3 + use Atex.Lexicon 4 + 5 + deflexicon(%{ 6 + "defs" => %{ 7 + "main" => %{ 8 + "description" => 9 + "Describe the credentials that should be included in the DID doc of an account that is migrating to this service.", 10 + "output" => %{ 11 + "encoding" => "application/json", 12 + "schema" => %{ 13 + "properties" => %{ 14 + "alsoKnownAs" => %{ 15 + "items" => %{"type" => "string"}, 16 + "type" => "array" 17 + }, 18 + "rotationKeys" => %{ 19 + "description" => 20 + "Recommended rotation keys for PLC dids. Should be undefined (or ignored) for did:webs.", 21 + "items" => %{"type" => "string"}, 22 + "type" => "array" 23 + }, 24 + "services" => %{"type" => "unknown"}, 25 + "verificationMethods" => %{"type" => "unknown"} 26 + }, 27 + "type" => "object" 28 + } 29 + }, 30 + "type" => "query" 31 + } 32 + }, 33 + "id" => "com.atproto.identity.getRecommendedDidCredentials", 34 + "lexicon" => 1 35 + }) 36 + end
+48
lib/atproto/com/atproto/identity/refreshIdentity.ex
···
··· 1 + defmodule Com.Atproto.Identity.RefreshIdentity do 2 + @moduledoc false 3 + use Atex.Lexicon 4 + 5 + deflexicon(%{ 6 + "defs" => %{ 7 + "main" => %{ 8 + "description" => 9 + "Request that the server re-resolve an identity (DID and handle). The server may ignore this request, or require authentication, depending on the role, implementation, and policy of the server.", 10 + "errors" => [ 11 + %{ 12 + "description" => 13 + "The resolution process confirmed that the handle does not resolve to any DID.", 14 + "name" => "HandleNotFound" 15 + }, 16 + %{ 17 + "description" => "The DID resolution process confirmed that there is no current DID.", 18 + "name" => "DidNotFound" 19 + }, 20 + %{ 21 + "description" => "The DID previously existed, but has been deactivated.", 22 + "name" => "DidDeactivated" 23 + } 24 + ], 25 + "input" => %{ 26 + "encoding" => "application/json", 27 + "schema" => %{ 28 + "properties" => %{ 29 + "identifier" => %{"format" => "at-identifier", "type" => "string"} 30 + }, 31 + "required" => ["identifier"], 32 + "type" => "object" 33 + } 34 + }, 35 + "output" => %{ 36 + "encoding" => "application/json", 37 + "schema" => %{ 38 + "ref" => "com.atproto.identity.defs#identityInfo", 39 + "type" => "ref" 40 + } 41 + }, 42 + "type" => "procedure" 43 + } 44 + }, 45 + "id" => "com.atproto.identity.refreshIdentity", 46 + "lexicon" => 1 47 + }) 48 + end
+16
lib/atproto/com/atproto/identity/requestPlcOperationSignature.ex
···
··· 1 + defmodule Com.Atproto.Identity.RequestPlcOperationSignature do 2 + @moduledoc false 3 + use Atex.Lexicon 4 + 5 + deflexicon(%{ 6 + "defs" => %{ 7 + "main" => %{ 8 + "description" => 9 + "Request an email with a code to in order to request a signed PLC operation. Requires Auth.", 10 + "type" => "procedure" 11 + } 12 + }, 13 + "id" => "com.atproto.identity.requestPlcOperationSignature", 14 + "lexicon" => 1 15 + }) 16 + end
+49
lib/atproto/com/atproto/identity/resolveDid.ex
···
··· 1 + defmodule Com.Atproto.Identity.ResolveDid do 2 + @moduledoc false 3 + use Atex.Lexicon 4 + 5 + deflexicon(%{ 6 + "defs" => %{ 7 + "main" => %{ 8 + "description" => "Resolves DID to DID document. Does not bi-directionally verify handle.", 9 + "errors" => [ 10 + %{ 11 + "description" => "The DID resolution process confirmed that there is no current DID.", 12 + "name" => "DidNotFound" 13 + }, 14 + %{ 15 + "description" => "The DID previously existed, but has been deactivated.", 16 + "name" => "DidDeactivated" 17 + } 18 + ], 19 + "output" => %{ 20 + "encoding" => "application/json", 21 + "schema" => %{ 22 + "properties" => %{ 23 + "didDoc" => %{ 24 + "description" => "The complete DID document for the identity.", 25 + "type" => "unknown" 26 + } 27 + }, 28 + "required" => ["didDoc"], 29 + "type" => "object" 30 + } 31 + }, 32 + "parameters" => %{ 33 + "properties" => %{ 34 + "did" => %{ 35 + "description" => "DID to resolve.", 36 + "format" => "did", 37 + "type" => "string" 38 + } 39 + }, 40 + "required" => ["did"], 41 + "type" => "params" 42 + }, 43 + "type" => "query" 44 + } 45 + }, 46 + "id" => "com.atproto.identity.resolveDid", 47 + "lexicon" => 1 48 + }) 49 + end
+42
lib/atproto/com/atproto/identity/resolveHandle.ex
···
··· 1 + defmodule Com.Atproto.Identity.ResolveHandle do 2 + @moduledoc false 3 + use Atex.Lexicon 4 + 5 + deflexicon(%{ 6 + "defs" => %{ 7 + "main" => %{ 8 + "description" => 9 + "Resolves an atproto handle (hostname) to a DID. Does not necessarily bi-directionally verify against the the DID document.", 10 + "errors" => [ 11 + %{ 12 + "description" => 13 + "The resolution process confirmed that the handle does not resolve to any DID.", 14 + "name" => "HandleNotFound" 15 + } 16 + ], 17 + "output" => %{ 18 + "encoding" => "application/json", 19 + "schema" => %{ 20 + "properties" => %{"did" => %{"format" => "did", "type" => "string"}}, 21 + "required" => ["did"], 22 + "type" => "object" 23 + } 24 + }, 25 + "parameters" => %{ 26 + "properties" => %{ 27 + "handle" => %{ 28 + "description" => "The handle to resolve.", 29 + "format" => "handle", 30 + "type" => "string" 31 + } 32 + }, 33 + "required" => ["handle"], 34 + "type" => "params" 35 + }, 36 + "type" => "query" 37 + } 38 + }, 39 + "id" => "com.atproto.identity.resolveHandle", 40 + "lexicon" => 1 41 + }) 42 + end
+49
lib/atproto/com/atproto/identity/resolveIdentity.ex
···
··· 1 + defmodule Com.Atproto.Identity.ResolveIdentity do 2 + @moduledoc false 3 + use Atex.Lexicon 4 + 5 + deflexicon(%{ 6 + "defs" => %{ 7 + "main" => %{ 8 + "description" => 9 + "Resolves an identity (DID or Handle) to a full identity (DID document and verified handle).", 10 + "errors" => [ 11 + %{ 12 + "description" => 13 + "The resolution process confirmed that the handle does not resolve to any DID.", 14 + "name" => "HandleNotFound" 15 + }, 16 + %{ 17 + "description" => "The DID resolution process confirmed that there is no current DID.", 18 + "name" => "DidNotFound" 19 + }, 20 + %{ 21 + "description" => "The DID previously existed, but has been deactivated.", 22 + "name" => "DidDeactivated" 23 + } 24 + ], 25 + "output" => %{ 26 + "encoding" => "application/json", 27 + "schema" => %{ 28 + "ref" => "com.atproto.identity.defs#identityInfo", 29 + "type" => "ref" 30 + } 31 + }, 32 + "parameters" => %{ 33 + "properties" => %{ 34 + "identifier" => %{ 35 + "description" => "Handle or DID to resolve.", 36 + "format" => "at-identifier", 37 + "type" => "string" 38 + } 39 + }, 40 + "required" => ["identifier"], 41 + "type" => "params" 42 + }, 43 + "type" => "query" 44 + } 45 + }, 46 + "id" => "com.atproto.identity.resolveIdentity", 47 + "lexicon" => 1 48 + }) 49 + end
+52
lib/atproto/com/atproto/identity/signPlcOperation.ex
···
··· 1 + defmodule Com.Atproto.Identity.SignPlcOperation do 2 + @moduledoc false 3 + use Atex.Lexicon 4 + 5 + deflexicon(%{ 6 + "defs" => %{ 7 + "main" => %{ 8 + "description" => 9 + "Signs a PLC operation to update some value(s) in the requesting DID's document.", 10 + "input" => %{ 11 + "encoding" => "application/json", 12 + "schema" => %{ 13 + "properties" => %{ 14 + "alsoKnownAs" => %{ 15 + "items" => %{"type" => "string"}, 16 + "type" => "array" 17 + }, 18 + "rotationKeys" => %{ 19 + "items" => %{"type" => "string"}, 20 + "type" => "array" 21 + }, 22 + "services" => %{"type" => "unknown"}, 23 + "token" => %{ 24 + "description" => 25 + "A token received through com.atproto.identity.requestPlcOperationSignature", 26 + "type" => "string" 27 + }, 28 + "verificationMethods" => %{"type" => "unknown"} 29 + }, 30 + "type" => "object" 31 + } 32 + }, 33 + "output" => %{ 34 + "encoding" => "application/json", 35 + "schema" => %{ 36 + "properties" => %{ 37 + "operation" => %{ 38 + "description" => "A signed DID PLC operation.", 39 + "type" => "unknown" 40 + } 41 + }, 42 + "required" => ["operation"], 43 + "type" => "object" 44 + } 45 + }, 46 + "type" => "procedure" 47 + } 48 + }, 49 + "id" => "com.atproto.identity.signPlcOperation", 50 + "lexicon" => 1 51 + }) 52 + end
+24
lib/atproto/com/atproto/identity/submitPlcOperation.ex
···
··· 1 + defmodule Com.Atproto.Identity.SubmitPlcOperation do 2 + @moduledoc false 3 + use Atex.Lexicon 4 + 5 + deflexicon(%{ 6 + "defs" => %{ 7 + "main" => %{ 8 + "description" => 9 + "Validates a PLC operation to ensure that it doesn't violate a service's constraints or get the identity into a bad state, then submits it to the PLC registry", 10 + "input" => %{ 11 + "encoding" => "application/json", 12 + "schema" => %{ 13 + "properties" => %{"operation" => %{"type" => "unknown"}}, 14 + "required" => ["operation"], 15 + "type" => "object" 16 + } 17 + }, 18 + "type" => "procedure" 19 + } 20 + }, 21 + "id" => "com.atproto.identity.submitPlcOperation", 22 + "lexicon" => 1 23 + }) 24 + end
+30
lib/atproto/com/atproto/identity/updateHandle.ex
···
··· 1 + defmodule Com.Atproto.Identity.UpdateHandle do 2 + @moduledoc false 3 + use Atex.Lexicon 4 + 5 + deflexicon(%{ 6 + "defs" => %{ 7 + "main" => %{ 8 + "description" => 9 + "Updates the current account's handle. Verifies handle validity, and updates did:plc document if necessary. Implemented by PDS, and requires auth.", 10 + "input" => %{ 11 + "encoding" => "application/json", 12 + "schema" => %{ 13 + "properties" => %{ 14 + "handle" => %{ 15 + "description" => "The new handle.", 16 + "format" => "handle", 17 + "type" => "string" 18 + } 19 + }, 20 + "required" => ["handle"], 21 + "type" => "object" 22 + } 23 + }, 24 + "type" => "procedure" 25 + } 26 + }, 27 + "id" => "com.atproto.identity.updateHandle", 28 + "lexicon" => 1 29 + }) 30 + end
+171
lib/atproto/com/atproto/label/defs.ex
···
··· 1 + defmodule Com.Atproto.Label.Defs do 2 + @moduledoc false 3 + use Atex.Lexicon 4 + 5 + deflexicon(%{ 6 + "defs" => %{ 7 + "label" => %{ 8 + "description" => "Metadata tag on an atproto resource (eg, repo or record).", 9 + "properties" => %{ 10 + "cid" => %{ 11 + "description" => 12 + "Optionally, CID specifying the specific version of 'uri' resource this label applies to.", 13 + "format" => "cid", 14 + "type" => "string" 15 + }, 16 + "cts" => %{ 17 + "description" => "Timestamp when this label was created.", 18 + "format" => "datetime", 19 + "type" => "string" 20 + }, 21 + "exp" => %{ 22 + "description" => "Timestamp at which this label expires (no longer applies).", 23 + "format" => "datetime", 24 + "type" => "string" 25 + }, 26 + "neg" => %{ 27 + "description" => "If true, this is a negation label, overwriting a previous label.", 28 + "type" => "boolean" 29 + }, 30 + "sig" => %{ 31 + "description" => "Signature of dag-cbor encoded label.", 32 + "type" => "bytes" 33 + }, 34 + "src" => %{ 35 + "description" => "DID of the actor who created this label.", 36 + "format" => "did", 37 + "type" => "string" 38 + }, 39 + "uri" => %{ 40 + "description" => 41 + "AT URI of the record, repository (account), or other resource that this label applies to.", 42 + "format" => "uri", 43 + "type" => "string" 44 + }, 45 + "val" => %{ 46 + "description" => "The short string name of the value or type of this label.", 47 + "maxLength" => 128, 48 + "type" => "string" 49 + }, 50 + "ver" => %{ 51 + "description" => "The AT Protocol version of the label object.", 52 + "type" => "integer" 53 + } 54 + }, 55 + "required" => ["src", "uri", "val", "cts"], 56 + "type" => "object" 57 + }, 58 + "labelValue" => %{ 59 + "knownValues" => [ 60 + "!hide", 61 + "!no-promote", 62 + "!warn", 63 + "!no-unauthenticated", 64 + "dmca-violation", 65 + "doxxing", 66 + "porn", 67 + "sexual", 68 + "nudity", 69 + "nsfl", 70 + "gore" 71 + ], 72 + "type" => "string" 73 + }, 74 + "labelValueDefinition" => %{ 75 + "description" => "Declares a label value and its expected interpretations and behaviors.", 76 + "properties" => %{ 77 + "adultOnly" => %{ 78 + "description" => 79 + "Does the user need to have adult content enabled in order to configure this label?", 80 + "type" => "boolean" 81 + }, 82 + "blurs" => %{ 83 + "description" => 84 + "What should this label hide in the UI, if applied? 'content' hides all of the target; 'media' hides the images/video/audio; 'none' hides nothing.", 85 + "knownValues" => ["content", "media", "none"], 86 + "type" => "string" 87 + }, 88 + "defaultSetting" => %{ 89 + "default" => "warn", 90 + "description" => "The default setting for this label.", 91 + "knownValues" => ["ignore", "warn", "hide"], 92 + "type" => "string" 93 + }, 94 + "identifier" => %{ 95 + "description" => 96 + "The value of the label being defined. Must only include lowercase ascii and the '-' character ([a-z-]+).", 97 + "maxGraphemes" => 100, 98 + "maxLength" => 100, 99 + "type" => "string" 100 + }, 101 + "locales" => %{ 102 + "items" => %{"ref" => "#labelValueDefinitionStrings", "type" => "ref"}, 103 + "type" => "array" 104 + }, 105 + "severity" => %{ 106 + "description" => 107 + "How should a client visually convey this label? 'inform' means neutral and informational; 'alert' means negative and warning; 'none' means show nothing.", 108 + "knownValues" => ["inform", "alert", "none"], 109 + "type" => "string" 110 + } 111 + }, 112 + "required" => ["identifier", "severity", "blurs", "locales"], 113 + "type" => "object" 114 + }, 115 + "labelValueDefinitionStrings" => %{ 116 + "description" => 117 + "Strings which describe the label in the UI, localized into a specific language.", 118 + "properties" => %{ 119 + "description" => %{ 120 + "description" => 121 + "A longer description of what the label means and why it might be applied.", 122 + "maxGraphemes" => 10000, 123 + "maxLength" => 100_000, 124 + "type" => "string" 125 + }, 126 + "lang" => %{ 127 + "description" => "The code of the language these strings are written in.", 128 + "format" => "language", 129 + "type" => "string" 130 + }, 131 + "name" => %{ 132 + "description" => "A short human-readable name for the label.", 133 + "maxGraphemes" => 64, 134 + "maxLength" => 640, 135 + "type" => "string" 136 + } 137 + }, 138 + "required" => ["lang", "name", "description"], 139 + "type" => "object" 140 + }, 141 + "selfLabel" => %{ 142 + "description" => 143 + "Metadata tag on an atproto record, published by the author within the record. Note that schemas should use #selfLabels, not #selfLabel.", 144 + "properties" => %{ 145 + "val" => %{ 146 + "description" => "The short string name of the value or type of this label.", 147 + "maxLength" => 128, 148 + "type" => "string" 149 + } 150 + }, 151 + "required" => ["val"], 152 + "type" => "object" 153 + }, 154 + "selfLabels" => %{ 155 + "description" => 156 + "Metadata tags on an atproto record, published by the author within the record.", 157 + "properties" => %{ 158 + "values" => %{ 159 + "items" => %{"ref" => "#selfLabel", "type" => "ref"}, 160 + "maxLength" => 10, 161 + "type" => "array" 162 + } 163 + }, 164 + "required" => ["values"], 165 + "type" => "object" 166 + } 167 + }, 168 + "id" => "com.atproto.label.defs", 169 + "lexicon" => 1 170 + }) 171 + end
+57
lib/atproto/com/atproto/label/queryLabels.ex
···
··· 1 + defmodule Com.Atproto.Label.QueryLabels do 2 + @moduledoc false 3 + use Atex.Lexicon 4 + 5 + deflexicon(%{ 6 + "defs" => %{ 7 + "main" => %{ 8 + "description" => 9 + "Find labels relevant to the provided AT-URI patterns. Public endpoint for moderation services, though may return different or additional results with auth.", 10 + "output" => %{ 11 + "encoding" => "application/json", 12 + "schema" => %{ 13 + "properties" => %{ 14 + "cursor" => %{"type" => "string"}, 15 + "labels" => %{ 16 + "items" => %{ 17 + "ref" => "com.atproto.label.defs#label", 18 + "type" => "ref" 19 + }, 20 + "type" => "array" 21 + } 22 + }, 23 + "required" => ["labels"], 24 + "type" => "object" 25 + } 26 + }, 27 + "parameters" => %{ 28 + "properties" => %{ 29 + "cursor" => %{"type" => "string"}, 30 + "limit" => %{ 31 + "default" => 50, 32 + "maximum" => 250, 33 + "minimum" => 1, 34 + "type" => "integer" 35 + }, 36 + "sources" => %{ 37 + "description" => "Optional list of label sources (DIDs) to filter on.", 38 + "items" => %{"format" => "did", "type" => "string"}, 39 + "type" => "array" 40 + }, 41 + "uriPatterns" => %{ 42 + "description" => 43 + "List of AT URI patterns to match (boolean 'OR'). Each may be a prefix (ending with '*'; will match inclusive of the string leading to '*'), or a full URI.", 44 + "items" => %{"type" => "string"}, 45 + "type" => "array" 46 + } 47 + }, 48 + "required" => ["uriPatterns"], 49 + "type" => "params" 50 + }, 51 + "type" => "query" 52 + } 53 + }, 54 + "id" => "com.atproto.label.queryLabels", 55 + "lexicon" => 1 56 + }) 57 + end
+48
lib/atproto/com/atproto/label/subscribeLabels.ex
···
··· 1 + defmodule Com.Atproto.Label.SubscribeLabels do 2 + @moduledoc false 3 + use Atex.Lexicon 4 + 5 + deflexicon(%{ 6 + "defs" => %{ 7 + "info" => %{ 8 + "properties" => %{ 9 + "message" => %{"type" => "string"}, 10 + "name" => %{"knownValues" => ["OutdatedCursor"], "type" => "string"} 11 + }, 12 + "required" => ["name"], 13 + "type" => "object" 14 + }, 15 + "labels" => %{ 16 + "properties" => %{ 17 + "labels" => %{ 18 + "items" => %{"ref" => "com.atproto.label.defs#label", "type" => "ref"}, 19 + "type" => "array" 20 + }, 21 + "seq" => %{"type" => "integer"} 22 + }, 23 + "required" => ["seq", "labels"], 24 + "type" => "object" 25 + }, 26 + "main" => %{ 27 + "description" => 28 + "Subscribe to stream of labels (and negations). Public endpoint implemented by mod services. Uses same sequencing scheme as repo event stream.", 29 + "errors" => [%{"name" => "FutureCursor"}], 30 + "message" => %{ 31 + "schema" => %{"refs" => ["#labels", "#info"], "type" => "union"} 32 + }, 33 + "parameters" => %{ 34 + "properties" => %{ 35 + "cursor" => %{ 36 + "description" => "The last known event seq number to backfill from.", 37 + "type" => "integer" 38 + } 39 + }, 40 + "type" => "params" 41 + }, 42 + "type" => "subscription" 43 + } 44 + }, 45 + "id" => "com.atproto.label.subscribeLabels", 46 + "lexicon" => 1 47 + }) 48 + end
+28
lib/atproto/com/atproto/lexicon/schema.ex
···
··· 1 + defmodule Com.Atproto.Lexicon.Schema do 2 + @moduledoc false 3 + use Atex.Lexicon 4 + 5 + deflexicon(%{ 6 + "defs" => %{ 7 + "main" => %{ 8 + "description" => 9 + "Representation of Lexicon schemas themselves, when published as atproto records. Note that the schema language is not defined in Lexicon; this meta schema currently only includes a single version field ('lexicon'). See the atproto specifications for description of the other expected top-level fields ('id', 'defs', etc).", 10 + "key" => "nsid", 11 + "record" => %{ 12 + "properties" => %{ 13 + "lexicon" => %{ 14 + "description" => 15 + "Indicates the 'version' of the Lexicon language. Must be '1' for the current atproto/Lexicon schema system.", 16 + "type" => "integer" 17 + } 18 + }, 19 + "required" => ["lexicon"], 20 + "type" => "object" 21 + }, 22 + "type" => "record" 23 + } 24 + }, 25 + "id" => "com.atproto.lexicon.schema", 26 + "lexicon" => 1 27 + }) 28 + end
+82
lib/atproto/com/atproto/moderation/createReport.ex
···
··· 1 + defmodule Com.Atproto.Moderation.CreateReport do 2 + @moduledoc false 3 + use Atex.Lexicon 4 + 5 + deflexicon(%{ 6 + "defs" => %{ 7 + "main" => %{ 8 + "description" => 9 + "Submit a moderation report regarding an atproto account or record. Implemented by moderation services (with PDS proxying), and requires auth.", 10 + "input" => %{ 11 + "encoding" => "application/json", 12 + "schema" => %{ 13 + "properties" => %{ 14 + "modTool" => %{"ref" => "#modTool", "type" => "ref"}, 15 + "reason" => %{ 16 + "description" => "Additional context about the content and violation.", 17 + "maxGraphemes" => 2000, 18 + "maxLength" => 20000, 19 + "type" => "string" 20 + }, 21 + "reasonType" => %{ 22 + "description" => "Indicates the broad category of violation the report is for.", 23 + "ref" => "com.atproto.moderation.defs#reasonType", 24 + "type" => "ref" 25 + }, 26 + "subject" => %{ 27 + "refs" => ["com.atproto.admin.defs#repoRef", "com.atproto.repo.strongRef"], 28 + "type" => "union" 29 + } 30 + }, 31 + "required" => ["reasonType", "subject"], 32 + "type" => "object" 33 + } 34 + }, 35 + "output" => %{ 36 + "encoding" => "application/json", 37 + "schema" => %{ 38 + "properties" => %{ 39 + "createdAt" => %{"format" => "datetime", "type" => "string"}, 40 + "id" => %{"type" => "integer"}, 41 + "reason" => %{ 42 + "maxGraphemes" => 2000, 43 + "maxLength" => 20000, 44 + "type" => "string" 45 + }, 46 + "reasonType" => %{ 47 + "ref" => "com.atproto.moderation.defs#reasonType", 48 + "type" => "ref" 49 + }, 50 + "reportedBy" => %{"format" => "did", "type" => "string"}, 51 + "subject" => %{ 52 + "refs" => ["com.atproto.admin.defs#repoRef", "com.atproto.repo.strongRef"], 53 + "type" => "union" 54 + } 55 + }, 56 + "required" => ["id", "reasonType", "subject", "reportedBy", "createdAt"], 57 + "type" => "object" 58 + } 59 + }, 60 + "type" => "procedure" 61 + }, 62 + "modTool" => %{ 63 + "description" => "Moderation tool information for tracing the source of the action", 64 + "properties" => %{ 65 + "meta" => %{ 66 + "description" => "Additional arbitrary metadata about the source", 67 + "type" => "unknown" 68 + }, 69 + "name" => %{ 70 + "description" => 71 + "Name/identifier of the source (e.g., 'bsky-app/android', 'bsky-web/chrome')", 72 + "type" => "string" 73 + } 74 + }, 75 + "required" => ["name"], 76 + "type" => "object" 77 + } 78 + }, 79 + "id" => "com.atproto.moderation.createReport", 80 + "lexicon" => 1 81 + }) 82 + end
+102
lib/atproto/com/atproto/moderation/defs.ex
···
··· 1 + defmodule Com.Atproto.Moderation.Defs do 2 + @moduledoc false 3 + use Atex.Lexicon 4 + 5 + deflexicon(%{ 6 + "defs" => %{ 7 + "reasonAppeal" => %{ 8 + "description" => "Appeal a previously taken moderation action", 9 + "type" => "token" 10 + }, 11 + "reasonMisleading" => %{ 12 + "description" => 13 + "Misleading identity, affiliation, or content. Prefer new lexicon definition `tools.ozone.report.defs#reasonMisleadingOther`.", 14 + "type" => "token" 15 + }, 16 + "reasonOther" => %{ 17 + "description" => 18 + "Reports not falling under another report category. Prefer new lexicon definition `tools.ozone.report.defs#reasonOther`.", 19 + "type" => "token" 20 + }, 21 + "reasonRude" => %{ 22 + "description" => 23 + "Rude, harassing, explicit, or otherwise unwelcoming behavior. Prefer new lexicon definition `tools.ozone.report.defs#reasonHarassmentOther`.", 24 + "type" => "token" 25 + }, 26 + "reasonSexual" => %{ 27 + "description" => 28 + "Unwanted or mislabeled sexual content. Prefer new lexicon definition `tools.ozone.report.defs#reasonSexualUnlabeled`.", 29 + "type" => "token" 30 + }, 31 + "reasonSpam" => %{ 32 + "description" => 33 + "Spam: frequent unwanted promotion, replies, mentions. Prefer new lexicon definition `tools.ozone.report.defs#reasonMisleadingSpam`.", 34 + "type" => "token" 35 + }, 36 + "reasonType" => %{ 37 + "knownValues" => [ 38 + "com.atproto.moderation.defs#reasonSpam", 39 + "com.atproto.moderation.defs#reasonViolation", 40 + "com.atproto.moderation.defs#reasonMisleading", 41 + "com.atproto.moderation.defs#reasonSexual", 42 + "com.atproto.moderation.defs#reasonRude", 43 + "com.atproto.moderation.defs#reasonOther", 44 + "com.atproto.moderation.defs#reasonAppeal", 45 + "tools.ozone.report.defs#reasonAppeal", 46 + "tools.ozone.report.defs#reasonOther", 47 + "tools.ozone.report.defs#reasonViolenceAnimal", 48 + "tools.ozone.report.defs#reasonViolenceThreats", 49 + "tools.ozone.report.defs#reasonViolenceGraphicContent", 50 + "tools.ozone.report.defs#reasonViolenceGlorification", 51 + "tools.ozone.report.defs#reasonViolenceExtremistContent", 52 + "tools.ozone.report.defs#reasonViolenceTrafficking", 53 + "tools.ozone.report.defs#reasonViolenceOther", 54 + "tools.ozone.report.defs#reasonSexualAbuseContent", 55 + "tools.ozone.report.defs#reasonSexualNCII", 56 + "tools.ozone.report.defs#reasonSexualDeepfake", 57 + "tools.ozone.report.defs#reasonSexualAnimal", 58 + "tools.ozone.report.defs#reasonSexualUnlabeled", 59 + "tools.ozone.report.defs#reasonSexualOther", 60 + "tools.ozone.report.defs#reasonChildSafetyCSAM", 61 + "tools.ozone.report.defs#reasonChildSafetyGroom", 62 + "tools.ozone.report.defs#reasonChildSafetyPrivacy", 63 + "tools.ozone.report.defs#reasonChildSafetyHarassment", 64 + "tools.ozone.report.defs#reasonChildSafetyOther", 65 + "tools.ozone.report.defs#reasonHarassmentTroll", 66 + "tools.ozone.report.defs#reasonHarassmentTargeted", 67 + "tools.ozone.report.defs#reasonHarassmentHateSpeech", 68 + "tools.ozone.report.defs#reasonHarassmentDoxxing", 69 + "tools.ozone.report.defs#reasonHarassmentOther", 70 + "tools.ozone.report.defs#reasonMisleadingBot", 71 + "tools.ozone.report.defs#reasonMisleadingImpersonation", 72 + "tools.ozone.report.defs#reasonMisleadingSpam", 73 + "tools.ozone.report.defs#reasonMisleadingScam", 74 + "tools.ozone.report.defs#reasonMisleadingElections", 75 + "tools.ozone.report.defs#reasonMisleadingOther", 76 + "tools.ozone.report.defs#reasonRuleSiteSecurity", 77 + "tools.ozone.report.defs#reasonRuleProhibitedSales", 78 + "tools.ozone.report.defs#reasonRuleBanEvasion", 79 + "tools.ozone.report.defs#reasonRuleOther", 80 + "tools.ozone.report.defs#reasonSelfHarmContent", 81 + "tools.ozone.report.defs#reasonSelfHarmED", 82 + "tools.ozone.report.defs#reasonSelfHarmStunts", 83 + "tools.ozone.report.defs#reasonSelfHarmSubstances", 84 + "tools.ozone.report.defs#reasonSelfHarmOther" 85 + ], 86 + "type" => "string" 87 + }, 88 + "reasonViolation" => %{ 89 + "description" => 90 + "Direct violation of server rules, laws, terms of service. Prefer new lexicon definition `tools.ozone.report.defs#reasonRuleOther`.", 91 + "type" => "token" 92 + }, 93 + "subjectType" => %{ 94 + "description" => "Tag describing a type of subject that might be reported.", 95 + "knownValues" => ["account", "record", "chat"], 96 + "type" => "string" 97 + } 98 + }, 99 + "id" => "com.atproto.moderation.defs", 100 + "lexicon" => 1 101 + }) 102 + end
+141
lib/atproto/com/atproto/repo/applyWrites.ex
···
··· 1 + defmodule Com.Atproto.Repo.ApplyWrites do 2 + @moduledoc false 3 + use Atex.Lexicon 4 + 5 + deflexicon(%{ 6 + "defs" => %{ 7 + "create" => %{ 8 + "description" => "Operation which creates a new record.", 9 + "properties" => %{ 10 + "collection" => %{"format" => "nsid", "type" => "string"}, 11 + "rkey" => %{ 12 + "description" => 13 + "NOTE: maxLength is redundant with record-key format. Keeping it temporarily to ensure backwards compatibility.", 14 + "format" => "record-key", 15 + "maxLength" => 512, 16 + "type" => "string" 17 + }, 18 + "value" => %{"type" => "unknown"} 19 + }, 20 + "required" => ["collection", "value"], 21 + "type" => "object" 22 + }, 23 + "createResult" => %{ 24 + "properties" => %{ 25 + "cid" => %{"format" => "cid", "type" => "string"}, 26 + "uri" => %{"format" => "at-uri", "type" => "string"}, 27 + "validationStatus" => %{ 28 + "knownValues" => ["valid", "unknown"], 29 + "type" => "string" 30 + } 31 + }, 32 + "required" => ["uri", "cid"], 33 + "type" => "object" 34 + }, 35 + "delete" => %{ 36 + "description" => "Operation which deletes an existing record.", 37 + "properties" => %{ 38 + "collection" => %{"format" => "nsid", "type" => "string"}, 39 + "rkey" => %{"format" => "record-key", "type" => "string"} 40 + }, 41 + "required" => ["collection", "rkey"], 42 + "type" => "object" 43 + }, 44 + "deleteResult" => %{ 45 + "properties" => %{}, 46 + "required" => [], 47 + "type" => "object" 48 + }, 49 + "main" => %{ 50 + "description" => 51 + "Apply a batch transaction of repository creates, updates, and deletes. Requires auth, implemented by PDS.", 52 + "errors" => [ 53 + %{ 54 + "description" => 55 + "Indicates that the 'swapCommit' parameter did not match current commit.", 56 + "name" => "InvalidSwap" 57 + } 58 + ], 59 + "input" => %{ 60 + "encoding" => "application/json", 61 + "schema" => %{ 62 + "properties" => %{ 63 + "repo" => %{ 64 + "description" => "The handle or DID of the repo (aka, current account).", 65 + "format" => "at-identifier", 66 + "type" => "string" 67 + }, 68 + "swapCommit" => %{ 69 + "description" => 70 + "If provided, the entire operation will fail if the current repo commit CID does not match this value. Used to prevent conflicting repo mutations.", 71 + "format" => "cid", 72 + "type" => "string" 73 + }, 74 + "validate" => %{ 75 + "description" => 76 + "Can be set to 'false' to skip Lexicon schema validation of record data across all operations, 'true' to require it, or leave unset to validate only for known Lexicons.", 77 + "type" => "boolean" 78 + }, 79 + "writes" => %{ 80 + "items" => %{ 81 + "closed" => true, 82 + "refs" => ["#create", "#update", "#delete"], 83 + "type" => "union" 84 + }, 85 + "type" => "array" 86 + } 87 + }, 88 + "required" => ["repo", "writes"], 89 + "type" => "object" 90 + } 91 + }, 92 + "output" => %{ 93 + "encoding" => "application/json", 94 + "schema" => %{ 95 + "properties" => %{ 96 + "commit" => %{ 97 + "ref" => "com.atproto.repo.defs#commitMeta", 98 + "type" => "ref" 99 + }, 100 + "results" => %{ 101 + "items" => %{ 102 + "closed" => true, 103 + "refs" => ["#createResult", "#updateResult", "#deleteResult"], 104 + "type" => "union" 105 + }, 106 + "type" => "array" 107 + } 108 + }, 109 + "required" => [], 110 + "type" => "object" 111 + } 112 + }, 113 + "type" => "procedure" 114 + }, 115 + "update" => %{ 116 + "description" => "Operation which updates an existing record.", 117 + "properties" => %{ 118 + "collection" => %{"format" => "nsid", "type" => "string"}, 119 + "rkey" => %{"format" => "record-key", "type" => "string"}, 120 + "value" => %{"type" => "unknown"} 121 + }, 122 + "required" => ["collection", "rkey", "value"], 123 + "type" => "object" 124 + }, 125 + "updateResult" => %{ 126 + "properties" => %{ 127 + "cid" => %{"format" => "cid", "type" => "string"}, 128 + "uri" => %{"format" => "at-uri", "type" => "string"}, 129 + "validationStatus" => %{ 130 + "knownValues" => ["valid", "unknown"], 131 + "type" => "string" 132 + } 133 + }, 134 + "required" => ["uri", "cid"], 135 + "type" => "object" 136 + } 137 + }, 138 + "id" => "com.atproto.repo.applyWrites", 139 + "lexicon" => 1 140 + }) 141 + end
+80
lib/atproto/com/atproto/repo/createRecord.ex
···
··· 1 + defmodule Com.Atproto.Repo.CreateRecord do 2 + @moduledoc false 3 + use Atex.Lexicon 4 + 5 + deflexicon(%{ 6 + "defs" => %{ 7 + "main" => %{ 8 + "description" => 9 + "Create a single new repository record. Requires auth, implemented by PDS.", 10 + "errors" => [ 11 + %{ 12 + "description" => "Indicates that 'swapCommit' didn't match current repo commit.", 13 + "name" => "InvalidSwap" 14 + } 15 + ], 16 + "input" => %{ 17 + "encoding" => "application/json", 18 + "schema" => %{ 19 + "properties" => %{ 20 + "collection" => %{ 21 + "description" => "The NSID of the record collection.", 22 + "format" => "nsid", 23 + "type" => "string" 24 + }, 25 + "record" => %{ 26 + "description" => "The record itself. Must contain a $type field.", 27 + "type" => "unknown" 28 + }, 29 + "repo" => %{ 30 + "description" => "The handle or DID of the repo (aka, current account).", 31 + "format" => "at-identifier", 32 + "type" => "string" 33 + }, 34 + "rkey" => %{ 35 + "description" => "The Record Key.", 36 + "format" => "record-key", 37 + "maxLength" => 512, 38 + "type" => "string" 39 + }, 40 + "swapCommit" => %{ 41 + "description" => "Compare and swap with the previous commit by CID.", 42 + "format" => "cid", 43 + "type" => "string" 44 + }, 45 + "validate" => %{ 46 + "description" => 47 + "Can be set to 'false' to skip Lexicon schema validation of record data, 'true' to require it, or leave unset to validate only for known Lexicons.", 48 + "type" => "boolean" 49 + } 50 + }, 51 + "required" => ["repo", "collection", "record"], 52 + "type" => "object" 53 + } 54 + }, 55 + "output" => %{ 56 + "encoding" => "application/json", 57 + "schema" => %{ 58 + "properties" => %{ 59 + "cid" => %{"format" => "cid", "type" => "string"}, 60 + "commit" => %{ 61 + "ref" => "com.atproto.repo.defs#commitMeta", 62 + "type" => "ref" 63 + }, 64 + "uri" => %{"format" => "at-uri", "type" => "string"}, 65 + "validationStatus" => %{ 66 + "knownValues" => ["valid", "unknown"], 67 + "type" => "string" 68 + } 69 + }, 70 + "required" => ["uri", "cid"], 71 + "type" => "object" 72 + } 73 + }, 74 + "type" => "procedure" 75 + } 76 + }, 77 + "id" => "com.atproto.repo.createRecord", 78 + "lexicon" => 1 79 + }) 80 + end
+19
lib/atproto/com/atproto/repo/defs.ex
···
··· 1 + defmodule Com.Atproto.Repo.Defs do 2 + @moduledoc false 3 + use Atex.Lexicon 4 + 5 + deflexicon(%{ 6 + "defs" => %{ 7 + "commitMeta" => %{ 8 + "properties" => %{ 9 + "cid" => %{"format" => "cid", "type" => "string"}, 10 + "rev" => %{"format" => "tid", "type" => "string"} 11 + }, 12 + "required" => ["cid", "rev"], 13 + "type" => "object" 14 + } 15 + }, 16 + "id" => "com.atproto.repo.defs", 17 + "lexicon" => 1 18 + }) 19 + end
+63
lib/atproto/com/atproto/repo/deleteRecord.ex
···
··· 1 + defmodule Com.Atproto.Repo.DeleteRecord do 2 + @moduledoc false 3 + use Atex.Lexicon 4 + 5 + deflexicon(%{ 6 + "defs" => %{ 7 + "main" => %{ 8 + "description" => 9 + "Delete a repository record, or ensure it doesn't exist. Requires auth, implemented by PDS.", 10 + "errors" => [%{"name" => "InvalidSwap"}], 11 + "input" => %{ 12 + "encoding" => "application/json", 13 + "schema" => %{ 14 + "properties" => %{ 15 + "collection" => %{ 16 + "description" => "The NSID of the record collection.", 17 + "format" => "nsid", 18 + "type" => "string" 19 + }, 20 + "repo" => %{ 21 + "description" => "The handle or DID of the repo (aka, current account).", 22 + "format" => "at-identifier", 23 + "type" => "string" 24 + }, 25 + "rkey" => %{ 26 + "description" => "The Record Key.", 27 + "format" => "record-key", 28 + "type" => "string" 29 + }, 30 + "swapCommit" => %{ 31 + "description" => "Compare and swap with the previous commit by CID.", 32 + "format" => "cid", 33 + "type" => "string" 34 + }, 35 + "swapRecord" => %{ 36 + "description" => "Compare and swap with the previous record by CID.", 37 + "format" => "cid", 38 + "type" => "string" 39 + } 40 + }, 41 + "required" => ["repo", "collection", "rkey"], 42 + "type" => "object" 43 + } 44 + }, 45 + "output" => %{ 46 + "encoding" => "application/json", 47 + "schema" => %{ 48 + "properties" => %{ 49 + "commit" => %{ 50 + "ref" => "com.atproto.repo.defs#commitMeta", 51 + "type" => "ref" 52 + } 53 + }, 54 + "type" => "object" 55 + } 56 + }, 57 + "type" => "procedure" 58 + } 59 + }, 60 + "id" => "com.atproto.repo.deleteRecord", 61 + "lexicon" => 1 62 + }) 63 + end
+53
lib/atproto/com/atproto/repo/describeRepo.ex
···
··· 1 + defmodule Com.Atproto.Repo.DescribeRepo do 2 + @moduledoc false 3 + use Atex.Lexicon 4 + 5 + deflexicon(%{ 6 + "defs" => %{ 7 + "main" => %{ 8 + "description" => 9 + "Get information about an account and repository, including the list of collections. Does not require auth.", 10 + "output" => %{ 11 + "encoding" => "application/json", 12 + "schema" => %{ 13 + "properties" => %{ 14 + "collections" => %{ 15 + "description" => 16 + "List of all the collections (NSIDs) for which this repo contains at least one record.", 17 + "items" => %{"format" => "nsid", "type" => "string"}, 18 + "type" => "array" 19 + }, 20 + "did" => %{"format" => "did", "type" => "string"}, 21 + "didDoc" => %{ 22 + "description" => "The complete DID document for this account.", 23 + "type" => "unknown" 24 + }, 25 + "handle" => %{"format" => "handle", "type" => "string"}, 26 + "handleIsCorrect" => %{ 27 + "description" => 28 + "Indicates if handle is currently valid (resolves bi-directionally)", 29 + "type" => "boolean" 30 + } 31 + }, 32 + "required" => ["handle", "did", "didDoc", "collections", "handleIsCorrect"], 33 + "type" => "object" 34 + } 35 + }, 36 + "parameters" => %{ 37 + "properties" => %{ 38 + "repo" => %{ 39 + "description" => "The handle or DID of the repo.", 40 + "format" => "at-identifier", 41 + "type" => "string" 42 + } 43 + }, 44 + "required" => ["repo"], 45 + "type" => "params" 46 + }, 47 + "type" => "query" 48 + } 49 + }, 50 + "id" => "com.atproto.repo.describeRepo", 51 + "lexicon" => 1 52 + }) 53 + end
+55
lib/atproto/com/atproto/repo/getRecord.ex
···
··· 1 + defmodule Com.Atproto.Repo.GetRecord do 2 + @moduledoc false 3 + use Atex.Lexicon 4 + 5 + deflexicon(%{ 6 + "defs" => %{ 7 + "main" => %{ 8 + "description" => "Get a single record from a repository. Does not require auth.", 9 + "errors" => [%{"name" => "RecordNotFound"}], 10 + "output" => %{ 11 + "encoding" => "application/json", 12 + "schema" => %{ 13 + "properties" => %{ 14 + "cid" => %{"format" => "cid", "type" => "string"}, 15 + "uri" => %{"format" => "at-uri", "type" => "string"}, 16 + "value" => %{"type" => "unknown"} 17 + }, 18 + "required" => ["uri", "value"], 19 + "type" => "object" 20 + } 21 + }, 22 + "parameters" => %{ 23 + "properties" => %{ 24 + "cid" => %{ 25 + "description" => 26 + "The CID of the version of the record. If not specified, then return the most recent version.", 27 + "format" => "cid", 28 + "type" => "string" 29 + }, 30 + "collection" => %{ 31 + "description" => "The NSID of the record collection.", 32 + "format" => "nsid", 33 + "type" => "string" 34 + }, 35 + "repo" => %{ 36 + "description" => "The handle or DID of the repo.", 37 + "format" => "at-identifier", 38 + "type" => "string" 39 + }, 40 + "rkey" => %{ 41 + "description" => "The Record Key.", 42 + "format" => "record-key", 43 + "type" => "string" 44 + } 45 + }, 46 + "required" => ["repo", "collection", "rkey"], 47 + "type" => "params" 48 + }, 49 + "type" => "query" 50 + } 51 + }, 52 + "id" => "com.atproto.repo.getRecord", 53 + "lexicon" => 1 54 + }) 55 + end
+17
lib/atproto/com/atproto/repo/importRepo.ex
···
··· 1 + defmodule Com.Atproto.Repo.ImportRepo do 2 + @moduledoc false 3 + use Atex.Lexicon 4 + 5 + deflexicon(%{ 6 + "defs" => %{ 7 + "main" => %{ 8 + "description" => 9 + "Import a repo in the form of a CAR file. Requires Content-Length HTTP header to be set.", 10 + "input" => %{"encoding" => "application/vnd.ipld.car"}, 11 + "type" => "procedure" 12 + } 13 + }, 14 + "id" => "com.atproto.repo.importRepo", 15 + "lexicon" => 1 16 + }) 17 + end
+50
lib/atproto/com/atproto/repo/listMissingBlobs.ex
···
··· 1 + defmodule Com.Atproto.Repo.ListMissingBlobs do 2 + @moduledoc false 3 + use Atex.Lexicon 4 + 5 + deflexicon(%{ 6 + "defs" => %{ 7 + "main" => %{ 8 + "description" => 9 + "Returns a list of missing blobs for the requesting account. Intended to be used in the account migration flow.", 10 + "output" => %{ 11 + "encoding" => "application/json", 12 + "schema" => %{ 13 + "properties" => %{ 14 + "blobs" => %{ 15 + "items" => %{"ref" => "#recordBlob", "type" => "ref"}, 16 + "type" => "array" 17 + }, 18 + "cursor" => %{"type" => "string"} 19 + }, 20 + "required" => ["blobs"], 21 + "type" => "object" 22 + } 23 + }, 24 + "parameters" => %{ 25 + "properties" => %{ 26 + "cursor" => %{"type" => "string"}, 27 + "limit" => %{ 28 + "default" => 500, 29 + "maximum" => 1000, 30 + "minimum" => 1, 31 + "type" => "integer" 32 + } 33 + }, 34 + "type" => "params" 35 + }, 36 + "type" => "query" 37 + }, 38 + "recordBlob" => %{ 39 + "properties" => %{ 40 + "cid" => %{"format" => "cid", "type" => "string"}, 41 + "recordUri" => %{"format" => "at-uri", "type" => "string"} 42 + }, 43 + "required" => ["cid", "recordUri"], 44 + "type" => "object" 45 + } 46 + }, 47 + "id" => "com.atproto.repo.listMissingBlobs", 48 + "lexicon" => 1 49 + }) 50 + end
+67
lib/atproto/com/atproto/repo/listRecords.ex
···
··· 1 + defmodule Com.Atproto.Repo.ListRecords do 2 + @moduledoc false 3 + use Atex.Lexicon 4 + 5 + deflexicon(%{ 6 + "defs" => %{ 7 + "main" => %{ 8 + "description" => 9 + "List a range of records in a repository, matching a specific collection. Does not require auth.", 10 + "output" => %{ 11 + "encoding" => "application/json", 12 + "schema" => %{ 13 + "properties" => %{ 14 + "cursor" => %{"type" => "string"}, 15 + "records" => %{ 16 + "items" => %{"ref" => "#record", "type" => "ref"}, 17 + "type" => "array" 18 + } 19 + }, 20 + "required" => ["records"], 21 + "type" => "object" 22 + } 23 + }, 24 + "parameters" => %{ 25 + "properties" => %{ 26 + "collection" => %{ 27 + "description" => "The NSID of the record type.", 28 + "format" => "nsid", 29 + "type" => "string" 30 + }, 31 + "cursor" => %{"type" => "string"}, 32 + "limit" => %{ 33 + "default" => 50, 34 + "description" => "The number of records to return.", 35 + "maximum" => 100, 36 + "minimum" => 1, 37 + "type" => "integer" 38 + }, 39 + "repo" => %{ 40 + "description" => "The handle or DID of the repo.", 41 + "format" => "at-identifier", 42 + "type" => "string" 43 + }, 44 + "reverse" => %{ 45 + "description" => "Flag to reverse the order of the returned records.", 46 + "type" => "boolean" 47 + } 48 + }, 49 + "required" => ["repo", "collection"], 50 + "type" => "params" 51 + }, 52 + "type" => "query" 53 + }, 54 + "record" => %{ 55 + "properties" => %{ 56 + "cid" => %{"format" => "cid", "type" => "string"}, 57 + "uri" => %{"format" => "at-uri", "type" => "string"}, 58 + "value" => %{"type" => "unknown"} 59 + }, 60 + "required" => ["uri", "cid", "value"], 61 + "type" => "object" 62 + } 63 + }, 64 + "id" => "com.atproto.repo.listRecords", 65 + "lexicon" => 1 66 + }) 67 + end
+82
lib/atproto/com/atproto/repo/putRecord.ex
···
··· 1 + defmodule Com.Atproto.Repo.PutRecord do 2 + @moduledoc false 3 + use Atex.Lexicon 4 + 5 + deflexicon(%{ 6 + "defs" => %{ 7 + "main" => %{ 8 + "description" => 9 + "Write a repository record, creating or updating it as needed. Requires auth, implemented by PDS.", 10 + "errors" => [%{"name" => "InvalidSwap"}], 11 + "input" => %{ 12 + "encoding" => "application/json", 13 + "schema" => %{ 14 + "nullable" => ["swapRecord"], 15 + "properties" => %{ 16 + "collection" => %{ 17 + "description" => "The NSID of the record collection.", 18 + "format" => "nsid", 19 + "type" => "string" 20 + }, 21 + "record" => %{ 22 + "description" => "The record to write.", 23 + "type" => "unknown" 24 + }, 25 + "repo" => %{ 26 + "description" => "The handle or DID of the repo (aka, current account).", 27 + "format" => "at-identifier", 28 + "type" => "string" 29 + }, 30 + "rkey" => %{ 31 + "description" => "The Record Key.", 32 + "format" => "record-key", 33 + "maxLength" => 512, 34 + "type" => "string" 35 + }, 36 + "swapCommit" => %{ 37 + "description" => "Compare and swap with the previous commit by CID.", 38 + "format" => "cid", 39 + "type" => "string" 40 + }, 41 + "swapRecord" => %{ 42 + "description" => 43 + "Compare and swap with the previous record by CID. WARNING: nullable and optional field; may cause problems with golang implementation", 44 + "format" => "cid", 45 + "type" => "string" 46 + }, 47 + "validate" => %{ 48 + "description" => 49 + "Can be set to 'false' to skip Lexicon schema validation of record data, 'true' to require it, or leave unset to validate only for known Lexicons.", 50 + "type" => "boolean" 51 + } 52 + }, 53 + "required" => ["repo", "collection", "rkey", "record"], 54 + "type" => "object" 55 + } 56 + }, 57 + "output" => %{ 58 + "encoding" => "application/json", 59 + "schema" => %{ 60 + "properties" => %{ 61 + "cid" => %{"format" => "cid", "type" => "string"}, 62 + "commit" => %{ 63 + "ref" => "com.atproto.repo.defs#commitMeta", 64 + "type" => "ref" 65 + }, 66 + "uri" => %{"format" => "at-uri", "type" => "string"}, 67 + "validationStatus" => %{ 68 + "knownValues" => ["valid", "unknown"], 69 + "type" => "string" 70 + } 71 + }, 72 + "required" => ["uri", "cid"], 73 + "type" => "object" 74 + } 75 + }, 76 + "type" => "procedure" 77 + } 78 + }, 79 + "id" => "com.atproto.repo.putRecord", 80 + "lexicon" => 1 81 + }) 82 + end
+20
lib/atproto/com/atproto/repo/strongRef.ex
···
··· 1 + defmodule Com.Atproto.Repo.StrongRef do 2 + @moduledoc false 3 + use Atex.Lexicon 4 + 5 + deflexicon(%{ 6 + "defs" => %{ 7 + "main" => %{ 8 + "properties" => %{ 9 + "cid" => %{"format" => "cid", "type" => "string"}, 10 + "uri" => %{"format" => "at-uri", "type" => "string"} 11 + }, 12 + "required" => ["uri", "cid"], 13 + "type" => "object" 14 + } 15 + }, 16 + "description" => "A URI with a content-hash fingerprint.", 17 + "id" => "com.atproto.repo.strongRef", 18 + "lexicon" => 1 19 + }) 20 + end
+25
lib/atproto/com/atproto/repo/uploadBlob.ex
···
··· 1 + defmodule Com.Atproto.Repo.UploadBlob do 2 + @moduledoc false 3 + use Atex.Lexicon 4 + 5 + deflexicon(%{ 6 + "defs" => %{ 7 + "main" => %{ 8 + "description" => 9 + "Upload a new blob, to be referenced from a repository record. The blob will be deleted if it is not referenced within a time window (eg, minutes). Blob restrictions (mimetype, size, etc) are enforced when the reference is created. Requires auth, implemented by PDS.", 10 + "input" => %{"encoding" => "*/*"}, 11 + "output" => %{ 12 + "encoding" => "application/json", 13 + "schema" => %{ 14 + "properties" => %{"blob" => %{"type" => "blob"}}, 15 + "required" => ["blob"], 16 + "type" => "object" 17 + } 18 + }, 19 + "type" => "procedure" 20 + } 21 + }, 22 + "id" => "com.atproto.repo.uploadBlob", 23 + "lexicon" => 1 24 + }) 25 + end
+16
lib/atproto/com/atproto/server/activateAccount.ex
···
··· 1 + defmodule Com.Atproto.Server.ActivateAccount do 2 + @moduledoc false 3 + use Atex.Lexicon 4 + 5 + deflexicon(%{ 6 + "defs" => %{ 7 + "main" => %{ 8 + "description" => 9 + "Activates a currently deactivated account. Used to finalize account migration after the account's repo is imported and identity is setup.", 10 + "type" => "procedure" 11 + } 12 + }, 13 + "id" => "com.atproto.server.activateAccount", 14 + "lexicon" => 1 15 + }) 16 + end
+44
lib/atproto/com/atproto/server/checkAccountStatus.ex
···
··· 1 + defmodule Com.Atproto.Server.CheckAccountStatus do 2 + @moduledoc false 3 + use Atex.Lexicon 4 + 5 + deflexicon(%{ 6 + "defs" => %{ 7 + "main" => %{ 8 + "description" => 9 + "Returns the status of an account, especially as pertaining to import or recovery. Can be called many times over the course of an account migration. Requires auth and can only be called pertaining to oneself.", 10 + "output" => %{ 11 + "encoding" => "application/json", 12 + "schema" => %{ 13 + "properties" => %{ 14 + "activated" => %{"type" => "boolean"}, 15 + "expectedBlobs" => %{"type" => "integer"}, 16 + "importedBlobs" => %{"type" => "integer"}, 17 + "indexedRecords" => %{"type" => "integer"}, 18 + "privateStateValues" => %{"type" => "integer"}, 19 + "repoBlocks" => %{"type" => "integer"}, 20 + "repoCommit" => %{"format" => "cid", "type" => "string"}, 21 + "repoRev" => %{"type" => "string"}, 22 + "validDid" => %{"type" => "boolean"} 23 + }, 24 + "required" => [ 25 + "activated", 26 + "validDid", 27 + "repoCommit", 28 + "repoRev", 29 + "repoBlocks", 30 + "indexedRecords", 31 + "privateStateValues", 32 + "expectedBlobs", 33 + "importedBlobs" 34 + ], 35 + "type" => "object" 36 + } 37 + }, 38 + "type" => "query" 39 + } 40 + }, 41 + "id" => "com.atproto.server.checkAccountStatus", 42 + "lexicon" => 1 43 + }) 44 + end
+33
lib/atproto/com/atproto/server/confirmEmail.ex
···
··· 1 + defmodule Com.Atproto.Server.ConfirmEmail do 2 + @moduledoc false 3 + use Atex.Lexicon 4 + 5 + deflexicon(%{ 6 + "defs" => %{ 7 + "main" => %{ 8 + "description" => 9 + "Confirm an email using a token from com.atproto.server.requestEmailConfirmation.", 10 + "errors" => [ 11 + %{"name" => "AccountNotFound"}, 12 + %{"name" => "ExpiredToken"}, 13 + %{"name" => "InvalidToken"}, 14 + %{"name" => "InvalidEmail"} 15 + ], 16 + "input" => %{ 17 + "encoding" => "application/json", 18 + "schema" => %{ 19 + "properties" => %{ 20 + "email" => %{"type" => "string"}, 21 + "token" => %{"type" => "string"} 22 + }, 23 + "required" => ["email", "token"], 24 + "type" => "object" 25 + } 26 + }, 27 + "type" => "procedure" 28 + } 29 + }, 30 + "id" => "com.atproto.server.confirmEmail", 31 + "lexicon" => 1 32 + }) 33 + end
+84
lib/atproto/com/atproto/server/createAccount.ex
···
··· 1 + defmodule Com.Atproto.Server.CreateAccount do 2 + @moduledoc false 3 + use Atex.Lexicon 4 + 5 + deflexicon(%{ 6 + "defs" => %{ 7 + "main" => %{ 8 + "description" => "Create an account. Implemented by PDS.", 9 + "errors" => [ 10 + %{"name" => "InvalidHandle"}, 11 + %{"name" => "InvalidPassword"}, 12 + %{"name" => "InvalidInviteCode"}, 13 + %{"name" => "HandleNotAvailable"}, 14 + %{"name" => "UnsupportedDomain"}, 15 + %{"name" => "UnresolvableDid"}, 16 + %{"name" => "IncompatibleDidDoc"} 17 + ], 18 + "input" => %{ 19 + "encoding" => "application/json", 20 + "schema" => %{ 21 + "properties" => %{ 22 + "did" => %{ 23 + "description" => "Pre-existing atproto DID, being imported to a new account.", 24 + "format" => "did", 25 + "type" => "string" 26 + }, 27 + "email" => %{"type" => "string"}, 28 + "handle" => %{ 29 + "description" => "Requested handle for the account.", 30 + "format" => "handle", 31 + "type" => "string" 32 + }, 33 + "inviteCode" => %{"type" => "string"}, 34 + "password" => %{ 35 + "description" => 36 + "Initial account password. May need to meet instance-specific password strength requirements.", 37 + "type" => "string" 38 + }, 39 + "plcOp" => %{ 40 + "description" => 41 + "A signed DID PLC operation to be submitted as part of importing an existing account to this instance. NOTE: this optional field may be updated when full account migration is implemented.", 42 + "type" => "unknown" 43 + }, 44 + "recoveryKey" => %{ 45 + "description" => 46 + "DID PLC rotation key (aka, recovery key) to be included in PLC creation operation.", 47 + "type" => "string" 48 + }, 49 + "verificationCode" => %{"type" => "string"}, 50 + "verificationPhone" => %{"type" => "string"} 51 + }, 52 + "required" => ["handle"], 53 + "type" => "object" 54 + } 55 + }, 56 + "output" => %{ 57 + "encoding" => "application/json", 58 + "schema" => %{ 59 + "description" => "Account login session returned on successful account creation.", 60 + "properties" => %{ 61 + "accessJwt" => %{"type" => "string"}, 62 + "did" => %{ 63 + "description" => "The DID of the new account.", 64 + "format" => "did", 65 + "type" => "string" 66 + }, 67 + "didDoc" => %{ 68 + "description" => "Complete DID document.", 69 + "type" => "unknown" 70 + }, 71 + "handle" => %{"format" => "handle", "type" => "string"}, 72 + "refreshJwt" => %{"type" => "string"} 73 + }, 74 + "required" => ["accessJwt", "refreshJwt", "handle", "did"], 75 + "type" => "object" 76 + } 77 + }, 78 + "type" => "procedure" 79 + } 80 + }, 81 + "id" => "com.atproto.server.createAccount", 82 + "lexicon" => 1 83 + }) 84 + end
+48
lib/atproto/com/atproto/server/createAppPassword.ex
···
··· 1 + defmodule Com.Atproto.Server.CreateAppPassword do 2 + @moduledoc false 3 + use Atex.Lexicon 4 + 5 + deflexicon(%{ 6 + "defs" => %{ 7 + "appPassword" => %{ 8 + "properties" => %{ 9 + "createdAt" => %{"format" => "datetime", "type" => "string"}, 10 + "name" => %{"type" => "string"}, 11 + "password" => %{"type" => "string"}, 12 + "privileged" => %{"type" => "boolean"} 13 + }, 14 + "required" => ["name", "password", "createdAt"], 15 + "type" => "object" 16 + }, 17 + "main" => %{ 18 + "description" => "Create an App Password.", 19 + "errors" => [%{"name" => "AccountTakedown"}], 20 + "input" => %{ 21 + "encoding" => "application/json", 22 + "schema" => %{ 23 + "properties" => %{ 24 + "name" => %{ 25 + "description" => "A short name for the App Password, to help distinguish them.", 26 + "type" => "string" 27 + }, 28 + "privileged" => %{ 29 + "description" => 30 + "If an app password has 'privileged' access to possibly sensitive account state. Meant for use with trusted clients.", 31 + "type" => "boolean" 32 + } 33 + }, 34 + "required" => ["name"], 35 + "type" => "object" 36 + } 37 + }, 38 + "output" => %{ 39 + "encoding" => "application/json", 40 + "schema" => %{"ref" => "#appPassword", "type" => "ref"} 41 + }, 42 + "type" => "procedure" 43 + } 44 + }, 45 + "id" => "com.atproto.server.createAppPassword", 46 + "lexicon" => 1 47 + }) 48 + end
+34
lib/atproto/com/atproto/server/createInviteCode.ex
···
··· 1 + defmodule Com.Atproto.Server.CreateInviteCode do 2 + @moduledoc false 3 + use Atex.Lexicon 4 + 5 + deflexicon(%{ 6 + "defs" => %{ 7 + "main" => %{ 8 + "description" => "Create an invite code.", 9 + "input" => %{ 10 + "encoding" => "application/json", 11 + "schema" => %{ 12 + "properties" => %{ 13 + "forAccount" => %{"format" => "did", "type" => "string"}, 14 + "useCount" => %{"type" => "integer"} 15 + }, 16 + "required" => ["useCount"], 17 + "type" => "object" 18 + } 19 + }, 20 + "output" => %{ 21 + "encoding" => "application/json", 22 + "schema" => %{ 23 + "properties" => %{"code" => %{"type" => "string"}}, 24 + "required" => ["code"], 25 + "type" => "object" 26 + } 27 + }, 28 + "type" => "procedure" 29 + } 30 + }, 31 + "id" => "com.atproto.server.createInviteCode", 32 + "lexicon" => 1 33 + }) 34 + end
+51
lib/atproto/com/atproto/server/createInviteCodes.ex
···
··· 1 + defmodule Com.Atproto.Server.CreateInviteCodes do 2 + @moduledoc false 3 + use Atex.Lexicon 4 + 5 + deflexicon(%{ 6 + "defs" => %{ 7 + "accountCodes" => %{ 8 + "properties" => %{ 9 + "account" => %{"type" => "string"}, 10 + "codes" => %{"items" => %{"type" => "string"}, "type" => "array"} 11 + }, 12 + "required" => ["account", "codes"], 13 + "type" => "object" 14 + }, 15 + "main" => %{ 16 + "description" => "Create invite codes.", 17 + "input" => %{ 18 + "encoding" => "application/json", 19 + "schema" => %{ 20 + "properties" => %{ 21 + "codeCount" => %{"default" => 1, "type" => "integer"}, 22 + "forAccounts" => %{ 23 + "items" => %{"format" => "did", "type" => "string"}, 24 + "type" => "array" 25 + }, 26 + "useCount" => %{"type" => "integer"} 27 + }, 28 + "required" => ["codeCount", "useCount"], 29 + "type" => "object" 30 + } 31 + }, 32 + "output" => %{ 33 + "encoding" => "application/json", 34 + "schema" => %{ 35 + "properties" => %{ 36 + "codes" => %{ 37 + "items" => %{"ref" => "#accountCodes", "type" => "ref"}, 38 + "type" => "array" 39 + } 40 + }, 41 + "required" => ["codes"], 42 + "type" => "object" 43 + } 44 + }, 45 + "type" => "procedure" 46 + } 47 + }, 48 + "id" => "com.atproto.server.createInviteCodes", 49 + "lexicon" => 1 50 + }) 51 + end
+64
lib/atproto/com/atproto/server/createSession.ex
···
··· 1 + defmodule Com.Atproto.Server.CreateSession do 2 + @moduledoc false 3 + use Atex.Lexicon 4 + 5 + deflexicon(%{ 6 + "defs" => %{ 7 + "main" => %{ 8 + "description" => "Create an authentication session.", 9 + "errors" => [ 10 + %{"name" => "AccountTakedown"}, 11 + %{"name" => "AuthFactorTokenRequired"} 12 + ], 13 + "input" => %{ 14 + "encoding" => "application/json", 15 + "schema" => %{ 16 + "properties" => %{ 17 + "allowTakendown" => %{ 18 + "description" => 19 + "When true, instead of throwing error for takendown accounts, a valid response with a narrow scoped token will be returned", 20 + "type" => "boolean" 21 + }, 22 + "authFactorToken" => %{"type" => "string"}, 23 + "identifier" => %{ 24 + "description" => 25 + "Handle or other identifier supported by the server for the authenticating user.", 26 + "type" => "string" 27 + }, 28 + "password" => %{"type" => "string"} 29 + }, 30 + "required" => ["identifier", "password"], 31 + "type" => "object" 32 + } 33 + }, 34 + "output" => %{ 35 + "encoding" => "application/json", 36 + "schema" => %{ 37 + "properties" => %{ 38 + "accessJwt" => %{"type" => "string"}, 39 + "active" => %{"type" => "boolean"}, 40 + "did" => %{"format" => "did", "type" => "string"}, 41 + "didDoc" => %{"type" => "unknown"}, 42 + "email" => %{"type" => "string"}, 43 + "emailAuthFactor" => %{"type" => "boolean"}, 44 + "emailConfirmed" => %{"type" => "boolean"}, 45 + "handle" => %{"format" => "handle", "type" => "string"}, 46 + "refreshJwt" => %{"type" => "string"}, 47 + "status" => %{ 48 + "description" => 49 + "If active=false, this optional field indicates a possible reason for why the account is not active. If active=false and no status is supplied, then the host makes no claim for why the repository is no longer being hosted.", 50 + "knownValues" => ["takendown", "suspended", "deactivated"], 51 + "type" => "string" 52 + } 53 + }, 54 + "required" => ["accessJwt", "refreshJwt", "handle", "did"], 55 + "type" => "object" 56 + } 57 + }, 58 + "type" => "procedure" 59 + } 60 + }, 61 + "id" => "com.atproto.server.createSession", 62 + "lexicon" => 1 63 + }) 64 + end
+30
lib/atproto/com/atproto/server/deactivateAccount.ex
···
··· 1 + defmodule Com.Atproto.Server.DeactivateAccount do 2 + @moduledoc false 3 + use Atex.Lexicon 4 + 5 + deflexicon(%{ 6 + "defs" => %{ 7 + "main" => %{ 8 + "description" => 9 + "Deactivates a currently active account. Stops serving of repo, and future writes to repo until reactivated. Used to finalize account migration with the old host after the account has been activated on the new host.", 10 + "input" => %{ 11 + "encoding" => "application/json", 12 + "schema" => %{ 13 + "properties" => %{ 14 + "deleteAfter" => %{ 15 + "description" => 16 + "A recommendation to server as to how long they should hold onto the deactivated account before deleting.", 17 + "format" => "datetime", 18 + "type" => "string" 19 + } 20 + }, 21 + "type" => "object" 22 + } 23 + }, 24 + "type" => "procedure" 25 + } 26 + }, 27 + "id" => "com.atproto.server.deactivateAccount", 28 + "lexicon" => 1 29 + }) 30 + end
+43
lib/atproto/com/atproto/server/defs.ex
···
··· 1 + defmodule Com.Atproto.Server.Defs do 2 + @moduledoc false 3 + use Atex.Lexicon 4 + 5 + deflexicon(%{ 6 + "defs" => %{ 7 + "inviteCode" => %{ 8 + "properties" => %{ 9 + "available" => %{"type" => "integer"}, 10 + "code" => %{"type" => "string"}, 11 + "createdAt" => %{"format" => "datetime", "type" => "string"}, 12 + "createdBy" => %{"type" => "string"}, 13 + "disabled" => %{"type" => "boolean"}, 14 + "forAccount" => %{"type" => "string"}, 15 + "uses" => %{ 16 + "items" => %{"ref" => "#inviteCodeUse", "type" => "ref"}, 17 + "type" => "array" 18 + } 19 + }, 20 + "required" => [ 21 + "code", 22 + "available", 23 + "disabled", 24 + "forAccount", 25 + "createdBy", 26 + "createdAt", 27 + "uses" 28 + ], 29 + "type" => "object" 30 + }, 31 + "inviteCodeUse" => %{ 32 + "properties" => %{ 33 + "usedAt" => %{"format" => "datetime", "type" => "string"}, 34 + "usedBy" => %{"format" => "did", "type" => "string"} 35 + }, 36 + "required" => ["usedBy", "usedAt"], 37 + "type" => "object" 38 + } 39 + }, 40 + "id" => "com.atproto.server.defs", 41 + "lexicon" => 1 42 + }) 43 + end
+29
lib/atproto/com/atproto/server/deleteAccount.ex
···
··· 1 + defmodule Com.Atproto.Server.DeleteAccount do 2 + @moduledoc false 3 + use Atex.Lexicon 4 + 5 + deflexicon(%{ 6 + "defs" => %{ 7 + "main" => %{ 8 + "description" => 9 + "Delete an actor's account with a token and password. Can only be called after requesting a deletion token. Requires auth.", 10 + "errors" => [%{"name" => "ExpiredToken"}, %{"name" => "InvalidToken"}], 11 + "input" => %{ 12 + "encoding" => "application/json", 13 + "schema" => %{ 14 + "properties" => %{ 15 + "did" => %{"format" => "did", "type" => "string"}, 16 + "password" => %{"type" => "string"}, 17 + "token" => %{"type" => "string"} 18 + }, 19 + "required" => ["did", "password", "token"], 20 + "type" => "object" 21 + } 22 + }, 23 + "type" => "procedure" 24 + } 25 + }, 26 + "id" => "com.atproto.server.deleteAccount", 27 + "lexicon" => 1 28 + }) 29 + end
+15
lib/atproto/com/atproto/server/deleteSession.ex
···
··· 1 + defmodule Com.Atproto.Server.DeleteSession do 2 + @moduledoc false 3 + use Atex.Lexicon 4 + 5 + deflexicon(%{ 6 + "defs" => %{ 7 + "main" => %{ 8 + "description" => "Delete the current session. Requires auth.", 9 + "type" => "procedure" 10 + } 11 + }, 12 + "id" => "com.atproto.server.deleteSession", 13 + "lexicon" => 1 14 + }) 15 + end
+62
lib/atproto/com/atproto/server/describeServer.ex
···
··· 1 + defmodule Com.Atproto.Server.DescribeServer do 2 + @moduledoc false 3 + use Atex.Lexicon 4 + 5 + deflexicon(%{ 6 + "defs" => %{ 7 + "contact" => %{ 8 + "properties" => %{"email" => %{"type" => "string"}}, 9 + "type" => "object" 10 + }, 11 + "links" => %{ 12 + "properties" => %{ 13 + "privacyPolicy" => %{"format" => "uri", "type" => "string"}, 14 + "termsOfService" => %{"format" => "uri", "type" => "string"} 15 + }, 16 + "type" => "object" 17 + }, 18 + "main" => %{ 19 + "description" => 20 + "Describes the server's account creation requirements and capabilities. Implemented by PDS.", 21 + "output" => %{ 22 + "encoding" => "application/json", 23 + "schema" => %{ 24 + "properties" => %{ 25 + "availableUserDomains" => %{ 26 + "description" => "List of domain suffixes that can be used in account handles.", 27 + "items" => %{"type" => "string"}, 28 + "type" => "array" 29 + }, 30 + "contact" => %{ 31 + "description" => "Contact information", 32 + "ref" => "#contact", 33 + "type" => "ref" 34 + }, 35 + "did" => %{"format" => "did", "type" => "string"}, 36 + "inviteCodeRequired" => %{ 37 + "description" => 38 + "If true, an invite code must be supplied to create an account on this instance.", 39 + "type" => "boolean" 40 + }, 41 + "links" => %{ 42 + "description" => "URLs of service policy documents.", 43 + "ref" => "#links", 44 + "type" => "ref" 45 + }, 46 + "phoneVerificationRequired" => %{ 47 + "description" => 48 + "If true, a phone verification token must be supplied to create an account on this instance.", 49 + "type" => "boolean" 50 + } 51 + }, 52 + "required" => ["did", "availableUserDomains"], 53 + "type" => "object" 54 + } 55 + }, 56 + "type" => "query" 57 + } 58 + }, 59 + "id" => "com.atproto.server.describeServer", 60 + "lexicon" => 1 61 + }) 62 + end
+44
lib/atproto/com/atproto/server/getAccountInviteCodes.ex
···
··· 1 + defmodule Com.Atproto.Server.GetAccountInviteCodes do 2 + @moduledoc false 3 + use Atex.Lexicon 4 + 5 + deflexicon(%{ 6 + "defs" => %{ 7 + "main" => %{ 8 + "description" => "Get all invite codes for the current account. Requires auth.", 9 + "errors" => [%{"name" => "DuplicateCreate"}], 10 + "output" => %{ 11 + "encoding" => "application/json", 12 + "schema" => %{ 13 + "properties" => %{ 14 + "codes" => %{ 15 + "items" => %{ 16 + "ref" => "com.atproto.server.defs#inviteCode", 17 + "type" => "ref" 18 + }, 19 + "type" => "array" 20 + } 21 + }, 22 + "required" => ["codes"], 23 + "type" => "object" 24 + } 25 + }, 26 + "parameters" => %{ 27 + "properties" => %{ 28 + "createAvailable" => %{ 29 + "default" => true, 30 + "description" => 31 + "Controls whether any new 'earned' but not 'created' invites should be created.", 32 + "type" => "boolean" 33 + }, 34 + "includeUsed" => %{"default" => true, "type" => "boolean"} 35 + }, 36 + "type" => "params" 37 + }, 38 + "type" => "query" 39 + } 40 + }, 41 + "id" => "com.atproto.server.getAccountInviteCodes", 42 + "lexicon" => 1 43 + }) 44 + end
+53
lib/atproto/com/atproto/server/getServiceAuth.ex
···
··· 1 + defmodule Com.Atproto.Server.GetServiceAuth do 2 + @moduledoc false 3 + use Atex.Lexicon 4 + 5 + deflexicon(%{ 6 + "defs" => %{ 7 + "main" => %{ 8 + "description" => 9 + "Get a signed token on behalf of the requesting DID for the requested service.", 10 + "errors" => [ 11 + %{ 12 + "description" => 13 + "Indicates that the requested expiration date is not a valid. May be in the past or may be reliant on the requested scopes.", 14 + "name" => "BadExpiration" 15 + } 16 + ], 17 + "output" => %{ 18 + "encoding" => "application/json", 19 + "schema" => %{ 20 + "properties" => %{"token" => %{"type" => "string"}}, 21 + "required" => ["token"], 22 + "type" => "object" 23 + } 24 + }, 25 + "parameters" => %{ 26 + "properties" => %{ 27 + "aud" => %{ 28 + "description" => 29 + "The DID of the service that the token will be used to authenticate with", 30 + "format" => "did", 31 + "type" => "string" 32 + }, 33 + "exp" => %{ 34 + "description" => 35 + "The time in Unix Epoch seconds that the JWT expires. Defaults to 60 seconds in the future. The service may enforce certain time bounds on tokens depending on the requested scope.", 36 + "type" => "integer" 37 + }, 38 + "lxm" => %{ 39 + "description" => "Lexicon (XRPC) method to bind the requested token to", 40 + "format" => "nsid", 41 + "type" => "string" 42 + } 43 + }, 44 + "required" => ["aud"], 45 + "type" => "params" 46 + }, 47 + "type" => "query" 48 + } 49 + }, 50 + "id" => "com.atproto.server.getServiceAuth", 51 + "lexicon" => 1 52 + }) 53 + end
+37
lib/atproto/com/atproto/server/getSession.ex
···
··· 1 + defmodule Com.Atproto.Server.GetSession do 2 + @moduledoc false 3 + use Atex.Lexicon 4 + 5 + deflexicon(%{ 6 + "defs" => %{ 7 + "main" => %{ 8 + "description" => "Get information about the current auth session. Requires auth.", 9 + "output" => %{ 10 + "encoding" => "application/json", 11 + "schema" => %{ 12 + "properties" => %{ 13 + "active" => %{"type" => "boolean"}, 14 + "did" => %{"format" => "did", "type" => "string"}, 15 + "didDoc" => %{"type" => "unknown"}, 16 + "email" => %{"type" => "string"}, 17 + "emailAuthFactor" => %{"type" => "boolean"}, 18 + "emailConfirmed" => %{"type" => "boolean"}, 19 + "handle" => %{"format" => "handle", "type" => "string"}, 20 + "status" => %{ 21 + "description" => 22 + "If active=false, this optional field indicates a possible reason for why the account is not active. If active=false and no status is supplied, then the host makes no claim for why the repository is no longer being hosted.", 23 + "knownValues" => ["takendown", "suspended", "deactivated"], 24 + "type" => "string" 25 + } 26 + }, 27 + "required" => ["handle", "did"], 28 + "type" => "object" 29 + } 30 + }, 31 + "type" => "query" 32 + } 33 + }, 34 + "id" => "com.atproto.server.getSession", 35 + "lexicon" => 1 36 + }) 37 + end
+38
lib/atproto/com/atproto/server/listAppPasswords.ex
···
··· 1 + defmodule Com.Atproto.Server.ListAppPasswords do 2 + @moduledoc false 3 + use Atex.Lexicon 4 + 5 + deflexicon(%{ 6 + "defs" => %{ 7 + "appPassword" => %{ 8 + "properties" => %{ 9 + "createdAt" => %{"format" => "datetime", "type" => "string"}, 10 + "name" => %{"type" => "string"}, 11 + "privileged" => %{"type" => "boolean"} 12 + }, 13 + "required" => ["name", "createdAt"], 14 + "type" => "object" 15 + }, 16 + "main" => %{ 17 + "description" => "List all App Passwords.", 18 + "errors" => [%{"name" => "AccountTakedown"}], 19 + "output" => %{ 20 + "encoding" => "application/json", 21 + "schema" => %{ 22 + "properties" => %{ 23 + "passwords" => %{ 24 + "items" => %{"ref" => "#appPassword", "type" => "ref"}, 25 + "type" => "array" 26 + } 27 + }, 28 + "required" => ["passwords"], 29 + "type" => "object" 30 + } 31 + }, 32 + "type" => "query" 33 + } 34 + }, 35 + "id" => "com.atproto.server.listAppPasswords", 36 + "lexicon" => 1 37 + }) 38 + end
+38
lib/atproto/com/atproto/server/refreshSession.ex
···
··· 1 + defmodule Com.Atproto.Server.RefreshSession do 2 + @moduledoc false 3 + use Atex.Lexicon 4 + 5 + deflexicon(%{ 6 + "defs" => %{ 7 + "main" => %{ 8 + "description" => 9 + "Refresh an authentication session. Requires auth using the 'refreshJwt' (not the 'accessJwt').", 10 + "errors" => [%{"name" => "AccountTakedown"}], 11 + "output" => %{ 12 + "encoding" => "application/json", 13 + "schema" => %{ 14 + "properties" => %{ 15 + "accessJwt" => %{"type" => "string"}, 16 + "active" => %{"type" => "boolean"}, 17 + "did" => %{"format" => "did", "type" => "string"}, 18 + "didDoc" => %{"type" => "unknown"}, 19 + "handle" => %{"format" => "handle", "type" => "string"}, 20 + "refreshJwt" => %{"type" => "string"}, 21 + "status" => %{ 22 + "description" => 23 + "Hosting status of the account. If not specified, then assume 'active'.", 24 + "knownValues" => ["takendown", "suspended", "deactivated"], 25 + "type" => "string" 26 + } 27 + }, 28 + "required" => ["accessJwt", "refreshJwt", "handle", "did"], 29 + "type" => "object" 30 + } 31 + }, 32 + "type" => "procedure" 33 + } 34 + }, 35 + "id" => "com.atproto.server.refreshSession", 36 + "lexicon" => 1 37 + }) 38 + end
+15
lib/atproto/com/atproto/server/requestAccountDelete.ex
···
··· 1 + defmodule Com.Atproto.Server.RequestAccountDelete do 2 + @moduledoc false 3 + use Atex.Lexicon 4 + 5 + deflexicon(%{ 6 + "defs" => %{ 7 + "main" => %{ 8 + "description" => "Initiate a user account deletion via email.", 9 + "type" => "procedure" 10 + } 11 + }, 12 + "id" => "com.atproto.server.requestAccountDelete", 13 + "lexicon" => 1 14 + }) 15 + end
+15
lib/atproto/com/atproto/server/requestEmailConfirmation.ex
···
··· 1 + defmodule Com.Atproto.Server.RequestEmailConfirmation do 2 + @moduledoc false 3 + use Atex.Lexicon 4 + 5 + deflexicon(%{ 6 + "defs" => %{ 7 + "main" => %{ 8 + "description" => "Request an email with a code to confirm ownership of email.", 9 + "type" => "procedure" 10 + } 11 + }, 12 + "id" => "com.atproto.server.requestEmailConfirmation", 13 + "lexicon" => 1 14 + }) 15 + end
+23
lib/atproto/com/atproto/server/requestEmailUpdate.ex
···
··· 1 + defmodule Com.Atproto.Server.RequestEmailUpdate do 2 + @moduledoc false 3 + use Atex.Lexicon 4 + 5 + deflexicon(%{ 6 + "defs" => %{ 7 + "main" => %{ 8 + "description" => "Request a token in order to update email.", 9 + "output" => %{ 10 + "encoding" => "application/json", 11 + "schema" => %{ 12 + "properties" => %{"tokenRequired" => %{"type" => "boolean"}}, 13 + "required" => ["tokenRequired"], 14 + "type" => "object" 15 + } 16 + }, 17 + "type" => "procedure" 18 + } 19 + }, 20 + "id" => "com.atproto.server.requestEmailUpdate", 21 + "lexicon" => 1 22 + }) 23 + end
+23
lib/atproto/com/atproto/server/requestPasswordReset.ex
···
··· 1 + defmodule Com.Atproto.Server.RequestPasswordReset do 2 + @moduledoc false 3 + use Atex.Lexicon 4 + 5 + deflexicon(%{ 6 + "defs" => %{ 7 + "main" => %{ 8 + "description" => "Initiate a user account password reset via email.", 9 + "input" => %{ 10 + "encoding" => "application/json", 11 + "schema" => %{ 12 + "properties" => %{"email" => %{"type" => "string"}}, 13 + "required" => ["email"], 14 + "type" => "object" 15 + } 16 + }, 17 + "type" => "procedure" 18 + } 19 + }, 20 + "id" => "com.atproto.server.requestPasswordReset", 21 + "lexicon" => 1 22 + }) 23 + end
+43
lib/atproto/com/atproto/server/reserveSigningKey.ex
···
··· 1 + defmodule Com.Atproto.Server.ReserveSigningKey do 2 + @moduledoc false 3 + use Atex.Lexicon 4 + 5 + deflexicon(%{ 6 + "defs" => %{ 7 + "main" => %{ 8 + "description" => 9 + "Reserve a repo signing key, for use with account creation. Necessary so that a DID PLC update operation can be constructed during an account migraiton. Public and does not require auth; implemented by PDS. NOTE: this endpoint may change when full account migration is implemented.", 10 + "input" => %{ 11 + "encoding" => "application/json", 12 + "schema" => %{ 13 + "properties" => %{ 14 + "did" => %{ 15 + "description" => "The DID to reserve a key for.", 16 + "format" => "did", 17 + "type" => "string" 18 + } 19 + }, 20 + "type" => "object" 21 + } 22 + }, 23 + "output" => %{ 24 + "encoding" => "application/json", 25 + "schema" => %{ 26 + "properties" => %{ 27 + "signingKey" => %{ 28 + "description" => 29 + "The public key for the reserved signing key, in did:key serialization.", 30 + "type" => "string" 31 + } 32 + }, 33 + "required" => ["signingKey"], 34 + "type" => "object" 35 + } 36 + }, 37 + "type" => "procedure" 38 + } 39 + }, 40 + "id" => "com.atproto.server.reserveSigningKey", 41 + "lexicon" => 1 42 + }) 43 + end
+27
lib/atproto/com/atproto/server/resetPassword.ex
···
··· 1 + defmodule Com.Atproto.Server.ResetPassword do 2 + @moduledoc false 3 + use Atex.Lexicon 4 + 5 + deflexicon(%{ 6 + "defs" => %{ 7 + "main" => %{ 8 + "description" => "Reset a user account password using a token.", 9 + "errors" => [%{"name" => "ExpiredToken"}, %{"name" => "InvalidToken"}], 10 + "input" => %{ 11 + "encoding" => "application/json", 12 + "schema" => %{ 13 + "properties" => %{ 14 + "password" => %{"type" => "string"}, 15 + "token" => %{"type" => "string"} 16 + }, 17 + "required" => ["token", "password"], 18 + "type" => "object" 19 + } 20 + }, 21 + "type" => "procedure" 22 + } 23 + }, 24 + "id" => "com.atproto.server.resetPassword", 25 + "lexicon" => 1 26 + }) 27 + end
+23
lib/atproto/com/atproto/server/revokeAppPassword.ex
···
··· 1 + defmodule Com.Atproto.Server.RevokeAppPassword do 2 + @moduledoc false 3 + use Atex.Lexicon 4 + 5 + deflexicon(%{ 6 + "defs" => %{ 7 + "main" => %{ 8 + "description" => "Revoke an App Password by name.", 9 + "input" => %{ 10 + "encoding" => "application/json", 11 + "schema" => %{ 12 + "properties" => %{"name" => %{"type" => "string"}}, 13 + "required" => ["name"], 14 + "type" => "object" 15 + } 16 + }, 17 + "type" => "procedure" 18 + } 19 + }, 20 + "id" => "com.atproto.server.revokeAppPassword", 21 + "lexicon" => 1 22 + }) 23 + end
+36
lib/atproto/com/atproto/server/updateEmail.ex
···
··· 1 + defmodule Com.Atproto.Server.UpdateEmail do 2 + @moduledoc false 3 + use Atex.Lexicon 4 + 5 + deflexicon(%{ 6 + "defs" => %{ 7 + "main" => %{ 8 + "description" => "Update an account's email.", 9 + "errors" => [ 10 + %{"name" => "ExpiredToken"}, 11 + %{"name" => "InvalidToken"}, 12 + %{"name" => "TokenRequired"} 13 + ], 14 + "input" => %{ 15 + "encoding" => "application/json", 16 + "schema" => %{ 17 + "properties" => %{ 18 + "email" => %{"type" => "string"}, 19 + "emailAuthFactor" => %{"type" => "boolean"}, 20 + "token" => %{ 21 + "description" => 22 + "Requires a token from com.atproto.sever.requestEmailUpdate if the account's email has been confirmed.", 23 + "type" => "string" 24 + } 25 + }, 26 + "required" => ["email"], 27 + "type" => "object" 28 + } 29 + }, 30 + "type" => "procedure" 31 + } 32 + }, 33 + "id" => "com.atproto.server.updateEmail", 34 + "lexicon" => 1 35 + }) 36 + end
+15
lib/atproto/com/atproto/sync/defs.ex
···
··· 1 + defmodule Com.Atproto.Sync.Defs do 2 + @moduledoc false 3 + use Atex.Lexicon 4 + 5 + deflexicon(%{ 6 + "defs" => %{ 7 + "hostStatus" => %{ 8 + "knownValues" => ["active", "idle", "offline", "throttled", "banned"], 9 + "type" => "string" 10 + } 11 + }, 12 + "id" => "com.atproto.sync.defs", 13 + "lexicon" => 1 14 + }) 15 + end
+40
lib/atproto/com/atproto/sync/getBlob.ex
···
··· 1 + defmodule Com.Atproto.Sync.GetBlob do 2 + @moduledoc false 3 + use Atex.Lexicon 4 + 5 + deflexicon(%{ 6 + "defs" => %{ 7 + "main" => %{ 8 + "description" => 9 + "Get a blob associated with a given account. Returns the full blob as originally uploaded. Does not require auth; implemented by PDS.", 10 + "errors" => [ 11 + %{"name" => "BlobNotFound"}, 12 + %{"name" => "RepoNotFound"}, 13 + %{"name" => "RepoTakendown"}, 14 + %{"name" => "RepoSuspended"}, 15 + %{"name" => "RepoDeactivated"} 16 + ], 17 + "output" => %{"encoding" => "*/*"}, 18 + "parameters" => %{ 19 + "properties" => %{ 20 + "cid" => %{ 21 + "description" => "The CID of the blob to fetch", 22 + "format" => "cid", 23 + "type" => "string" 24 + }, 25 + "did" => %{ 26 + "description" => "The DID of the account.", 27 + "format" => "did", 28 + "type" => "string" 29 + } 30 + }, 31 + "required" => ["did", "cid"], 32 + "type" => "params" 33 + }, 34 + "type" => "query" 35 + } 36 + }, 37 + "id" => "com.atproto.sync.getBlob", 38 + "lexicon" => 1 39 + }) 40 + end
+39
lib/atproto/com/atproto/sync/getBlocks.ex
···
··· 1 + defmodule Com.Atproto.Sync.GetBlocks do 2 + @moduledoc false 3 + use Atex.Lexicon 4 + 5 + deflexicon(%{ 6 + "defs" => %{ 7 + "main" => %{ 8 + "description" => 9 + "Get data blocks from a given repo, by CID. For example, intermediate MST nodes, or records. Does not require auth; implemented by PDS.", 10 + "errors" => [ 11 + %{"name" => "BlockNotFound"}, 12 + %{"name" => "RepoNotFound"}, 13 + %{"name" => "RepoTakendown"}, 14 + %{"name" => "RepoSuspended"}, 15 + %{"name" => "RepoDeactivated"} 16 + ], 17 + "output" => %{"encoding" => "application/vnd.ipld.car"}, 18 + "parameters" => %{ 19 + "properties" => %{ 20 + "cids" => %{ 21 + "items" => %{"format" => "cid", "type" => "string"}, 22 + "type" => "array" 23 + }, 24 + "did" => %{ 25 + "description" => "The DID of the repo.", 26 + "format" => "did", 27 + "type" => "string" 28 + } 29 + }, 30 + "required" => ["did", "cids"], 31 + "type" => "params" 32 + }, 33 + "type" => "query" 34 + } 35 + }, 36 + "id" => "com.atproto.sync.getBlocks", 37 + "lexicon" => 1 38 + }) 39 + end
+27
lib/atproto/com/atproto/sync/getCheckout.ex
···
··· 1 + defmodule Com.Atproto.Sync.GetCheckout do 2 + @moduledoc false 3 + use Atex.Lexicon 4 + 5 + deflexicon(%{ 6 + "defs" => %{ 7 + "main" => %{ 8 + "description" => "DEPRECATED - please use com.atproto.sync.getRepo instead", 9 + "output" => %{"encoding" => "application/vnd.ipld.car"}, 10 + "parameters" => %{ 11 + "properties" => %{ 12 + "did" => %{ 13 + "description" => "The DID of the repo.", 14 + "format" => "did", 15 + "type" => "string" 16 + } 17 + }, 18 + "required" => ["did"], 19 + "type" => "params" 20 + }, 21 + "type" => "query" 22 + } 23 + }, 24 + "id" => "com.atproto.sync.getCheckout", 25 + "lexicon" => 1 26 + }) 27 + end
+35
lib/atproto/com/atproto/sync/getHead.ex
···
··· 1 + defmodule Com.Atproto.Sync.GetHead do 2 + @moduledoc false 3 + use Atex.Lexicon 4 + 5 + deflexicon(%{ 6 + "defs" => %{ 7 + "main" => %{ 8 + "description" => "DEPRECATED - please use com.atproto.sync.getLatestCommit instead", 9 + "errors" => [%{"name" => "HeadNotFound"}], 10 + "output" => %{ 11 + "encoding" => "application/json", 12 + "schema" => %{ 13 + "properties" => %{"root" => %{"format" => "cid", "type" => "string"}}, 14 + "required" => ["root"], 15 + "type" => "object" 16 + } 17 + }, 18 + "parameters" => %{ 19 + "properties" => %{ 20 + "did" => %{ 21 + "description" => "The DID of the repo.", 22 + "format" => "did", 23 + "type" => "string" 24 + } 25 + }, 26 + "required" => ["did"], 27 + "type" => "params" 28 + }, 29 + "type" => "query" 30 + } 31 + }, 32 + "id" => "com.atproto.sync.getHead", 33 + "lexicon" => 1 34 + }) 35 + end
+51
lib/atproto/com/atproto/sync/getHostStatus.ex
···
··· 1 + defmodule Com.Atproto.Sync.GetHostStatus do 2 + @moduledoc false 3 + use Atex.Lexicon 4 + 5 + deflexicon(%{ 6 + "defs" => %{ 7 + "main" => %{ 8 + "description" => 9 + "Returns information about a specified upstream host, as consumed by the server. Implemented by relays.", 10 + "errors" => [%{"name" => "HostNotFound"}], 11 + "output" => %{ 12 + "encoding" => "application/json", 13 + "schema" => %{ 14 + "properties" => %{ 15 + "accountCount" => %{ 16 + "description" => 17 + "Number of accounts on the server which are associated with the upstream host. Note that the upstream may actually have more accounts.", 18 + "type" => "integer" 19 + }, 20 + "hostname" => %{"type" => "string"}, 21 + "seq" => %{ 22 + "description" => 23 + "Recent repo stream event sequence number. May be delayed from actual stream processing (eg, persisted cursor not in-memory cursor).", 24 + "type" => "integer" 25 + }, 26 + "status" => %{ 27 + "ref" => "com.atproto.sync.defs#hostStatus", 28 + "type" => "ref" 29 + } 30 + }, 31 + "required" => ["hostname"], 32 + "type" => "object" 33 + } 34 + }, 35 + "parameters" => %{ 36 + "properties" => %{ 37 + "hostname" => %{ 38 + "description" => "Hostname of the host (eg, PDS or relay) being queried.", 39 + "type" => "string" 40 + } 41 + }, 42 + "required" => ["hostname"], 43 + "type" => "params" 44 + }, 45 + "type" => "query" 46 + } 47 + }, 48 + "id" => "com.atproto.sync.getHostStatus", 49 + "lexicon" => 1 50 + }) 51 + end
+44
lib/atproto/com/atproto/sync/getLatestCommit.ex
···
··· 1 + defmodule Com.Atproto.Sync.GetLatestCommit do 2 + @moduledoc false 3 + use Atex.Lexicon 4 + 5 + deflexicon(%{ 6 + "defs" => %{ 7 + "main" => %{ 8 + "description" => 9 + "Get the current commit CID & revision of the specified repo. Does not require auth.", 10 + "errors" => [ 11 + %{"name" => "RepoNotFound"}, 12 + %{"name" => "RepoTakendown"}, 13 + %{"name" => "RepoSuspended"}, 14 + %{"name" => "RepoDeactivated"} 15 + ], 16 + "output" => %{ 17 + "encoding" => "application/json", 18 + "schema" => %{ 19 + "properties" => %{ 20 + "cid" => %{"format" => "cid", "type" => "string"}, 21 + "rev" => %{"format" => "tid", "type" => "string"} 22 + }, 23 + "required" => ["cid", "rev"], 24 + "type" => "object" 25 + } 26 + }, 27 + "parameters" => %{ 28 + "properties" => %{ 29 + "did" => %{ 30 + "description" => "The DID of the repo.", 31 + "format" => "did", 32 + "type" => "string" 33 + } 34 + }, 35 + "required" => ["did"], 36 + "type" => "params" 37 + }, 38 + "type" => "query" 39 + } 40 + }, 41 + "id" => "com.atproto.sync.getLatestCommit", 42 + "lexicon" => 1 43 + }) 44 + end
+41
lib/atproto/com/atproto/sync/getRecord.ex
···
··· 1 + defmodule Com.Atproto.Sync.GetRecord do 2 + @moduledoc false 3 + use Atex.Lexicon 4 + 5 + deflexicon(%{ 6 + "defs" => %{ 7 + "main" => %{ 8 + "description" => 9 + "Get data blocks needed to prove the existence or non-existence of record in the current version of repo. Does not require auth.", 10 + "errors" => [ 11 + %{"name" => "RecordNotFound"}, 12 + %{"name" => "RepoNotFound"}, 13 + %{"name" => "RepoTakendown"}, 14 + %{"name" => "RepoSuspended"}, 15 + %{"name" => "RepoDeactivated"} 16 + ], 17 + "output" => %{"encoding" => "application/vnd.ipld.car"}, 18 + "parameters" => %{ 19 + "properties" => %{ 20 + "collection" => %{"format" => "nsid", "type" => "string"}, 21 + "did" => %{ 22 + "description" => "The DID of the repo.", 23 + "format" => "did", 24 + "type" => "string" 25 + }, 26 + "rkey" => %{ 27 + "description" => "Record Key", 28 + "format" => "record-key", 29 + "type" => "string" 30 + } 31 + }, 32 + "required" => ["did", "collection", "rkey"], 33 + "type" => "params" 34 + }, 35 + "type" => "query" 36 + } 37 + }, 38 + "id" => "com.atproto.sync.getRecord", 39 + "lexicon" => 1 40 + }) 41 + end
+39
lib/atproto/com/atproto/sync/getRepo.ex
···
··· 1 + defmodule Com.Atproto.Sync.GetRepo do 2 + @moduledoc false 3 + use Atex.Lexicon 4 + 5 + deflexicon(%{ 6 + "defs" => %{ 7 + "main" => %{ 8 + "description" => 9 + "Download a repository export as CAR file. Optionally only a 'diff' since a previous revision. Does not require auth; implemented by PDS.", 10 + "errors" => [ 11 + %{"name" => "RepoNotFound"}, 12 + %{"name" => "RepoTakendown"}, 13 + %{"name" => "RepoSuspended"}, 14 + %{"name" => "RepoDeactivated"} 15 + ], 16 + "output" => %{"encoding" => "application/vnd.ipld.car"}, 17 + "parameters" => %{ 18 + "properties" => %{ 19 + "did" => %{ 20 + "description" => "The DID of the repo.", 21 + "format" => "did", 22 + "type" => "string" 23 + }, 24 + "since" => %{ 25 + "description" => "The revision ('rev') of the repo to create a diff from.", 26 + "format" => "tid", 27 + "type" => "string" 28 + } 29 + }, 30 + "required" => ["did"], 31 + "type" => "params" 32 + }, 33 + "type" => "query" 34 + } 35 + }, 36 + "id" => "com.atproto.sync.getRepo", 37 + "lexicon" => 1 38 + }) 39 + end
+57
lib/atproto/com/atproto/sync/getRepoStatus.ex
···
··· 1 + defmodule Com.Atproto.Sync.GetRepoStatus do 2 + @moduledoc false 3 + use Atex.Lexicon 4 + 5 + deflexicon(%{ 6 + "defs" => %{ 7 + "main" => %{ 8 + "description" => 9 + "Get the hosting status for a repository, on this server. Expected to be implemented by PDS and Relay.", 10 + "errors" => [%{"name" => "RepoNotFound"}], 11 + "output" => %{ 12 + "encoding" => "application/json", 13 + "schema" => %{ 14 + "properties" => %{ 15 + "active" => %{"type" => "boolean"}, 16 + "did" => %{"format" => "did", "type" => "string"}, 17 + "rev" => %{ 18 + "description" => "Optional field, the current rev of the repo, if active=true", 19 + "format" => "tid", 20 + "type" => "string" 21 + }, 22 + "status" => %{ 23 + "description" => 24 + "If active=false, this optional field indicates a possible reason for why the account is not active. If active=false and no status is supplied, then the host makes no claim for why the repository is no longer being hosted.", 25 + "knownValues" => [ 26 + "takendown", 27 + "suspended", 28 + "deleted", 29 + "deactivated", 30 + "desynchronized", 31 + "throttled" 32 + ], 33 + "type" => "string" 34 + } 35 + }, 36 + "required" => ["did", "active"], 37 + "type" => "object" 38 + } 39 + }, 40 + "parameters" => %{ 41 + "properties" => %{ 42 + "did" => %{ 43 + "description" => "The DID of the repo.", 44 + "format" => "did", 45 + "type" => "string" 46 + } 47 + }, 48 + "required" => ["did"], 49 + "type" => "params" 50 + }, 51 + "type" => "query" 52 + } 53 + }, 54 + "id" => "com.atproto.sync.getRepoStatus", 55 + "lexicon" => 1 56 + }) 57 + end
+59
lib/atproto/com/atproto/sync/listBlobs.ex
···
··· 1 + defmodule Com.Atproto.Sync.ListBlobs do 2 + @moduledoc false 3 + use Atex.Lexicon 4 + 5 + deflexicon(%{ 6 + "defs" => %{ 7 + "main" => %{ 8 + "description" => 9 + "List blob CIDs for an account, since some repo revision. Does not require auth; implemented by PDS.", 10 + "errors" => [ 11 + %{"name" => "RepoNotFound"}, 12 + %{"name" => "RepoTakendown"}, 13 + %{"name" => "RepoSuspended"}, 14 + %{"name" => "RepoDeactivated"} 15 + ], 16 + "output" => %{ 17 + "encoding" => "application/json", 18 + "schema" => %{ 19 + "properties" => %{ 20 + "cids" => %{ 21 + "items" => %{"format" => "cid", "type" => "string"}, 22 + "type" => "array" 23 + }, 24 + "cursor" => %{"type" => "string"} 25 + }, 26 + "required" => ["cids"], 27 + "type" => "object" 28 + } 29 + }, 30 + "parameters" => %{ 31 + "properties" => %{ 32 + "cursor" => %{"type" => "string"}, 33 + "did" => %{ 34 + "description" => "The DID of the repo.", 35 + "format" => "did", 36 + "type" => "string" 37 + }, 38 + "limit" => %{ 39 + "default" => 500, 40 + "maximum" => 1000, 41 + "minimum" => 1, 42 + "type" => "integer" 43 + }, 44 + "since" => %{ 45 + "description" => "Optional revision of the repo to list blobs since.", 46 + "format" => "tid", 47 + "type" => "string" 48 + } 49 + }, 50 + "required" => ["did"], 51 + "type" => "params" 52 + }, 53 + "type" => "query" 54 + } 55 + }, 56 + "id" => "com.atproto.sync.listBlobs", 57 + "lexicon" => 1 58 + }) 59 + end
+64
lib/atproto/com/atproto/sync/listHosts.ex
···
··· 1 + defmodule Com.Atproto.Sync.ListHosts do 2 + @moduledoc false 3 + use Atex.Lexicon 4 + 5 + deflexicon(%{ 6 + "defs" => %{ 7 + "host" => %{ 8 + "properties" => %{ 9 + "accountCount" => %{"type" => "integer"}, 10 + "hostname" => %{ 11 + "description" => "hostname of server; not a URL (no scheme)", 12 + "type" => "string" 13 + }, 14 + "seq" => %{ 15 + "description" => 16 + "Recent repo stream event sequence number. May be delayed from actual stream processing (eg, persisted cursor not in-memory cursor).", 17 + "type" => "integer" 18 + }, 19 + "status" => %{ 20 + "ref" => "com.atproto.sync.defs#hostStatus", 21 + "type" => "ref" 22 + } 23 + }, 24 + "required" => ["hostname"], 25 + "type" => "object" 26 + }, 27 + "main" => %{ 28 + "description" => 29 + "Enumerates upstream hosts (eg, PDS or relay instances) that this service consumes from. Implemented by relays.", 30 + "output" => %{ 31 + "encoding" => "application/json", 32 + "schema" => %{ 33 + "properties" => %{ 34 + "cursor" => %{"type" => "string"}, 35 + "hosts" => %{ 36 + "description" => 37 + "Sort order is not formally specified. Recommended order is by time host was first seen by the server, with oldest first.", 38 + "items" => %{"ref" => "#host", "type" => "ref"}, 39 + "type" => "array" 40 + } 41 + }, 42 + "required" => ["hosts"], 43 + "type" => "object" 44 + } 45 + }, 46 + "parameters" => %{ 47 + "properties" => %{ 48 + "cursor" => %{"type" => "string"}, 49 + "limit" => %{ 50 + "default" => 200, 51 + "maximum" => 1000, 52 + "minimum" => 1, 53 + "type" => "integer" 54 + } 55 + }, 56 + "type" => "params" 57 + }, 58 + "type" => "query" 59 + } 60 + }, 61 + "id" => "com.atproto.sync.listHosts", 62 + "lexicon" => 1 63 + }) 64 + end
+69
lib/atproto/com/atproto/sync/listRepos.ex
···
··· 1 + defmodule Com.Atproto.Sync.ListRepos do 2 + @moduledoc false 3 + use Atex.Lexicon 4 + 5 + deflexicon(%{ 6 + "defs" => %{ 7 + "main" => %{ 8 + "description" => 9 + "Enumerates all the DID, rev, and commit CID for all repos hosted by this service. Does not require auth; implemented by PDS and Relay.", 10 + "output" => %{ 11 + "encoding" => "application/json", 12 + "schema" => %{ 13 + "properties" => %{ 14 + "cursor" => %{"type" => "string"}, 15 + "repos" => %{ 16 + "items" => %{"ref" => "#repo", "type" => "ref"}, 17 + "type" => "array" 18 + } 19 + }, 20 + "required" => ["repos"], 21 + "type" => "object" 22 + } 23 + }, 24 + "parameters" => %{ 25 + "properties" => %{ 26 + "cursor" => %{"type" => "string"}, 27 + "limit" => %{ 28 + "default" => 500, 29 + "maximum" => 1000, 30 + "minimum" => 1, 31 + "type" => "integer" 32 + } 33 + }, 34 + "type" => "params" 35 + }, 36 + "type" => "query" 37 + }, 38 + "repo" => %{ 39 + "properties" => %{ 40 + "active" => %{"type" => "boolean"}, 41 + "did" => %{"format" => "did", "type" => "string"}, 42 + "head" => %{ 43 + "description" => "Current repo commit CID", 44 + "format" => "cid", 45 + "type" => "string" 46 + }, 47 + "rev" => %{"format" => "tid", "type" => "string"}, 48 + "status" => %{ 49 + "description" => 50 + "If active=false, this optional field indicates a possible reason for why the account is not active. If active=false and no status is supplied, then the host makes no claim for why the repository is no longer being hosted.", 51 + "knownValues" => [ 52 + "takendown", 53 + "suspended", 54 + "deleted", 55 + "deactivated", 56 + "desynchronized", 57 + "throttled" 58 + ], 59 + "type" => "string" 60 + } 61 + }, 62 + "required" => ["did", "head", "rev"], 63 + "type" => "object" 64 + } 65 + }, 66 + "id" => "com.atproto.sync.listRepos", 67 + "lexicon" => 1 68 + }) 69 + end
+51
lib/atproto/com/atproto/sync/listReposByCollection.ex
···
··· 1 + defmodule Com.Atproto.Sync.ListReposByCollection do 2 + @moduledoc false 3 + use Atex.Lexicon 4 + 5 + deflexicon(%{ 6 + "defs" => %{ 7 + "main" => %{ 8 + "description" => 9 + "Enumerates all the DIDs which have records with the given collection NSID.", 10 + "output" => %{ 11 + "encoding" => "application/json", 12 + "schema" => %{ 13 + "properties" => %{ 14 + "cursor" => %{"type" => "string"}, 15 + "repos" => %{ 16 + "items" => %{"ref" => "#repo", "type" => "ref"}, 17 + "type" => "array" 18 + } 19 + }, 20 + "required" => ["repos"], 21 + "type" => "object" 22 + } 23 + }, 24 + "parameters" => %{ 25 + "properties" => %{ 26 + "collection" => %{"format" => "nsid", "type" => "string"}, 27 + "cursor" => %{"type" => "string"}, 28 + "limit" => %{ 29 + "default" => 500, 30 + "description" => 31 + "Maximum size of response set. Recommend setting a large maximum (1000+) when enumerating large DID lists.", 32 + "maximum" => 2000, 33 + "minimum" => 1, 34 + "type" => "integer" 35 + } 36 + }, 37 + "required" => ["collection"], 38 + "type" => "params" 39 + }, 40 + "type" => "query" 41 + }, 42 + "repo" => %{ 43 + "properties" => %{"did" => %{"format" => "did", "type" => "string"}}, 44 + "required" => ["did"], 45 + "type" => "object" 46 + } 47 + }, 48 + "id" => "com.atproto.sync.listReposByCollection", 49 + "lexicon" => 1 50 + }) 51 + end
+30
lib/atproto/com/atproto/sync/notifyOfUpdate.ex
···
··· 1 + defmodule Com.Atproto.Sync.NotifyOfUpdate do 2 + @moduledoc false 3 + use Atex.Lexicon 4 + 5 + deflexicon(%{ 6 + "defs" => %{ 7 + "main" => %{ 8 + "description" => 9 + "Notify a crawling service of a recent update, and that crawling should resume. Intended use is after a gap between repo stream events caused the crawling service to disconnect. Does not require auth; implemented by Relay. DEPRECATED: just use com.atproto.sync.requestCrawl", 10 + "input" => %{ 11 + "encoding" => "application/json", 12 + "schema" => %{ 13 + "properties" => %{ 14 + "hostname" => %{ 15 + "description" => 16 + "Hostname of the current service (usually a PDS) that is notifying of update.", 17 + "type" => "string" 18 + } 19 + }, 20 + "required" => ["hostname"], 21 + "type" => "object" 22 + } 23 + }, 24 + "type" => "procedure" 25 + } 26 + }, 27 + "id" => "com.atproto.sync.notifyOfUpdate", 28 + "lexicon" => 1 29 + }) 30 + end
+31
lib/atproto/com/atproto/sync/requestCrawl.ex
···
··· 1 + defmodule Com.Atproto.Sync.RequestCrawl do 2 + @moduledoc false 3 + use Atex.Lexicon 4 + 5 + deflexicon(%{ 6 + "defs" => %{ 7 + "main" => %{ 8 + "description" => 9 + "Request a service to persistently crawl hosted repos. Expected use is new PDS instances declaring their existence to Relays. Does not require auth.", 10 + "errors" => [%{"name" => "HostBanned"}], 11 + "input" => %{ 12 + "encoding" => "application/json", 13 + "schema" => %{ 14 + "properties" => %{ 15 + "hostname" => %{ 16 + "description" => 17 + "Hostname of the current service (eg, PDS) that is requesting to be crawled.", 18 + "type" => "string" 19 + } 20 + }, 21 + "required" => ["hostname"], 22 + "type" => "object" 23 + } 24 + }, 25 + "type" => "procedure" 26 + } 27 + }, 28 + "id" => "com.atproto.sync.requestCrawl", 29 + "lexicon" => 1 30 + }) 31 + end
+238
lib/atproto/com/atproto/sync/subscribeRepos.ex
···
··· 1 + defmodule Com.Atproto.Sync.SubscribeRepos do 2 + @moduledoc false 3 + use Atex.Lexicon 4 + 5 + deflexicon(%{ 6 + "defs" => %{ 7 + "account" => %{ 8 + "description" => 9 + "Represents a change to an account's status on a host (eg, PDS or Relay). The semantics of this event are that the status is at the host which emitted the event, not necessarily that at the currently active PDS. Eg, a Relay takedown would emit a takedown with active=false, even if the PDS is still active.", 10 + "properties" => %{ 11 + "active" => %{ 12 + "description" => 13 + "Indicates that the account has a repository which can be fetched from the host that emitted this event.", 14 + "type" => "boolean" 15 + }, 16 + "did" => %{"format" => "did", "type" => "string"}, 17 + "seq" => %{"type" => "integer"}, 18 + "status" => %{ 19 + "description" => 20 + "If active=false, this optional field indicates a reason for why the account is not active.", 21 + "knownValues" => [ 22 + "takendown", 23 + "suspended", 24 + "deleted", 25 + "deactivated", 26 + "desynchronized", 27 + "throttled" 28 + ], 29 + "type" => "string" 30 + }, 31 + "time" => %{"format" => "datetime", "type" => "string"} 32 + }, 33 + "required" => ["seq", "did", "time", "active"], 34 + "type" => "object" 35 + }, 36 + "commit" => %{ 37 + "description" => 38 + "Represents an update of repository state. Note that empty commits are allowed, which include no repo data changes, but an update to rev and signature.", 39 + "nullable" => ["since"], 40 + "properties" => %{ 41 + "blobs" => %{ 42 + "items" => %{ 43 + "description" => 44 + "DEPRECATED -- will soon always be empty. List of new blobs (by CID) referenced by records in this commit.", 45 + "type" => "cid-link" 46 + }, 47 + "type" => "array" 48 + }, 49 + "blocks" => %{ 50 + "description" => 51 + "CAR file containing relevant blocks, as a diff since the previous repo state. The commit must be included as a block, and the commit block CID must be the first entry in the CAR header 'roots' list.", 52 + "maxLength" => 2_000_000, 53 + "type" => "bytes" 54 + }, 55 + "commit" => %{ 56 + "description" => "Repo commit object CID.", 57 + "type" => "cid-link" 58 + }, 59 + "ops" => %{ 60 + "items" => %{ 61 + "description" => 62 + "List of repo mutation operations in this commit (eg, records created, updated, or deleted).", 63 + "ref" => "#repoOp", 64 + "type" => "ref" 65 + }, 66 + "maxLength" => 200, 67 + "type" => "array" 68 + }, 69 + "prevData" => %{ 70 + "description" => 71 + "The root CID of the MST tree for the previous commit from this repo (indicated by the 'since' revision field in this message). Corresponds to the 'data' field in the repo commit object. NOTE: this field is effectively required for the 'inductive' version of firehose.", 72 + "type" => "cid-link" 73 + }, 74 + "rebase" => %{ 75 + "description" => "DEPRECATED -- unused", 76 + "type" => "boolean" 77 + }, 78 + "repo" => %{ 79 + "description" => 80 + "The repo this event comes from. Note that all other message types name this field 'did'.", 81 + "format" => "did", 82 + "type" => "string" 83 + }, 84 + "rev" => %{ 85 + "description" => 86 + "The rev of the emitted commit. Note that this information is also in the commit object included in blocks, unless this is a tooBig event.", 87 + "format" => "tid", 88 + "type" => "string" 89 + }, 90 + "seq" => %{ 91 + "description" => "The stream sequence number of this message.", 92 + "type" => "integer" 93 + }, 94 + "since" => %{ 95 + "description" => "The rev of the last emitted commit from this repo (if any).", 96 + "format" => "tid", 97 + "type" => "string" 98 + }, 99 + "time" => %{ 100 + "description" => "Timestamp of when this message was originally broadcast.", 101 + "format" => "datetime", 102 + "type" => "string" 103 + }, 104 + "tooBig" => %{ 105 + "description" => 106 + "DEPRECATED -- replaced by #sync event and data limits. Indicates that this commit contained too many ops, or data size was too large. Consumers will need to make a separate request to get missing data.", 107 + "type" => "boolean" 108 + } 109 + }, 110 + "required" => [ 111 + "seq", 112 + "rebase", 113 + "tooBig", 114 + "repo", 115 + "commit", 116 + "rev", 117 + "since", 118 + "blocks", 119 + "ops", 120 + "blobs", 121 + "time" 122 + ], 123 + "type" => "object" 124 + }, 125 + "identity" => %{ 126 + "description" => 127 + "Represents a change to an account's identity. Could be an updated handle, signing key, or pds hosting endpoint. Serves as a prod to all downstream services to refresh their identity cache.", 128 + "properties" => %{ 129 + "did" => %{"format" => "did", "type" => "string"}, 130 + "handle" => %{ 131 + "description" => 132 + "The current handle for the account, or 'handle.invalid' if validation fails. This field is optional, might have been validated or passed-through from an upstream source. Semantics and behaviors for PDS vs Relay may evolve in the future; see atproto specs for more details.", 133 + "format" => "handle", 134 + "type" => "string" 135 + }, 136 + "seq" => %{"type" => "integer"}, 137 + "time" => %{"format" => "datetime", "type" => "string"} 138 + }, 139 + "required" => ["seq", "did", "time"], 140 + "type" => "object" 141 + }, 142 + "info" => %{ 143 + "properties" => %{ 144 + "message" => %{"type" => "string"}, 145 + "name" => %{"knownValues" => ["OutdatedCursor"], "type" => "string"} 146 + }, 147 + "required" => ["name"], 148 + "type" => "object" 149 + }, 150 + "main" => %{ 151 + "description" => 152 + "Repository event stream, aka Firehose endpoint. Outputs repo commits with diff data, and identity update events, for all repositories on the current server. See the atproto specifications for details around stream sequencing, repo versioning, CAR diff format, and more. Public and does not require auth; implemented by PDS and Relay.", 153 + "errors" => [ 154 + %{"name" => "FutureCursor"}, 155 + %{ 156 + "description" => 157 + "If the consumer of the stream can not keep up with events, and a backlog gets too large, the server will drop the connection.", 158 + "name" => "ConsumerTooSlow" 159 + } 160 + ], 161 + "message" => %{ 162 + "schema" => %{ 163 + "refs" => ["#commit", "#sync", "#identity", "#account", "#info"], 164 + "type" => "union" 165 + } 166 + }, 167 + "parameters" => %{ 168 + "properties" => %{ 169 + "cursor" => %{ 170 + "description" => "The last known event seq number to backfill from.", 171 + "type" => "integer" 172 + } 173 + }, 174 + "type" => "params" 175 + }, 176 + "type" => "subscription" 177 + }, 178 + "repoOp" => %{ 179 + "description" => "A repo operation, ie a mutation of a single record.", 180 + "nullable" => ["cid"], 181 + "properties" => %{ 182 + "action" => %{ 183 + "knownValues" => ["create", "update", "delete"], 184 + "type" => "string" 185 + }, 186 + "cid" => %{ 187 + "description" => "For creates and updates, the new record CID. For deletions, null.", 188 + "type" => "cid-link" 189 + }, 190 + "path" => %{"type" => "string"}, 191 + "prev" => %{ 192 + "description" => 193 + "For updates and deletes, the previous record CID (required for inductive firehose). For creations, field should not be defined.", 194 + "type" => "cid-link" 195 + } 196 + }, 197 + "required" => ["action", "path", "cid"], 198 + "type" => "object" 199 + }, 200 + "sync" => %{ 201 + "description" => 202 + "Updates the repo to a new state, without necessarily including that state on the firehose. Used to recover from broken commit streams, data loss incidents, or in situations where upstream host does not know recent state of the repository.", 203 + "properties" => %{ 204 + "blocks" => %{ 205 + "description" => 206 + "CAR file containing the commit, as a block. The CAR header must include the commit block CID as the first 'root'.", 207 + "maxLength" => 10000, 208 + "type" => "bytes" 209 + }, 210 + "did" => %{ 211 + "description" => 212 + "The account this repo event corresponds to. Must match that in the commit object.", 213 + "format" => "did", 214 + "type" => "string" 215 + }, 216 + "rev" => %{ 217 + "description" => 218 + "The rev of the commit. This value must match that in the commit object.", 219 + "type" => "string" 220 + }, 221 + "seq" => %{ 222 + "description" => "The stream sequence number of this message.", 223 + "type" => "integer" 224 + }, 225 + "time" => %{ 226 + "description" => "Timestamp of when this message was originally broadcast.", 227 + "format" => "datetime", 228 + "type" => "string" 229 + } 230 + }, 231 + "required" => ["seq", "did", "blocks", "rev", "time"], 232 + "type" => "object" 233 + } 234 + }, 235 + "id" => "com.atproto.sync.subscribeRepos", 236 + "lexicon" => 1 237 + }) 238 + end
+1 -1
lib/mix/tasks/atex.lexicons.ex
··· 44 @aliases [o: :output] 45 @template_path Path.expand("../../../priv/templates/lexicon.eex", __DIR__) 46 47 - @impl Mix.Task 48 def run(args) do 49 {options, globs} = OptionParser.parse!(args, switches: @switches, aliases: @aliases) 50
··· 44 @aliases [o: :output] 45 @template_path Path.expand("../../../priv/templates/lexicon.eex", __DIR__) 46 47 + @impl true 48 def run(args) do 49 {options, globs} = OptionParser.parse!(args, switches: @switches, aliases: @aliases) 50
+19 -4
mix.exs
··· 1 defmodule Atex.MixProject do 2 use Mix.Project 3 4 - @version "0.4.0" 5 @github "https://github.com/cometsh/atex" 6 @tangled "https://tangled.sh/@comet.sh/atex" 7 ··· 35 {:typedstruct, "~> 0.5"}, 36 {:ex_cldr, "~> 2.42"}, 37 {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, 38 - {:ex_doc, "~> 0.34", only: :dev, runtime: false, warn_if_outdated: true} 39 ] 40 end 41 ··· 50 [ 51 extras: [ 52 LICENSE: [title: "License"], 53 - "README.md": [title: "Overview"] 54 ], 55 main: "readme", 56 source_url: @github, 57 source_ref: "v#{@version}", 58 - formatters: ["html"] 59 ] 60 end 61 end
··· 1 defmodule Atex.MixProject do 2 use Mix.Project 3 4 + @version "0.7.0" 5 @github "https://github.com/cometsh/atex" 6 @tangled "https://tangled.sh/@comet.sh/atex" 7 ··· 35 {:typedstruct, "~> 0.5"}, 36 {:ex_cldr, "~> 2.42"}, 37 {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, 38 + {:ex_doc, "~> 0.39", only: :dev, runtime: false, warn_if_outdated: true}, 39 + {:plug, "~> 1.18"}, 40 + {:jason, "~> 1.4"}, 41 + {:jose, "~> 1.11"}, 42 + {:bandit, "~> 1.0", only: [:dev, :test]}, 43 + {:con_cache, "~> 1.1"}, 44 + {:mutex, "~> 3.0"}, 45 + {:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false} 46 ] 47 end 48 ··· 57 [ 58 extras: [ 59 LICENSE: [title: "License"], 60 + "README.md": [title: "Overview"], 61 + "CHANGELOG.md": [title: "Changelog"] 62 ], 63 main: "readme", 64 source_url: @github, 65 source_ref: "v#{@version}", 66 + formatters: ["html"], 67 + groups_for_modules: [ 68 + "Data types": [Atex.AtURI, Atex.DID, Atex.Handle, Atex.NSID, Atex.TID], 69 + XRPC: ~r/^Atex\.XRPC/, 70 + OAuth: [Atex.Config.OAuth, Atex.OAuth, Atex.OAuth.Plug], 71 + Lexicons: ~r/^Atex\.Lexicon/, 72 + Identity: ~r/^Atex\.IdentityResolver/ 73 + ] 74 ] 75 end 76 end
+19 -9
mix.lock
··· 1 %{ 2 "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, 3 - "cldr_utils": {:hex, :cldr_utils, "2.28.3", "d0ac5ed25913349dfaca8b7fe14722d588d8ccfa3e335b0510c7cc3f3c54d4e6", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:certifi, "~> 2.5", [hex: :certifi, repo: "hexpm", optional: true]}, {:decimal, "~> 1.9 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "40083cd9a5d187f12d675cfeeb39285f0d43e7b7f2143765161b72205d57ffb5"}, 4 - "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"}, 5 "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, 6 "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, 7 - "ex_cldr": {:hex, :ex_cldr, "2.42.0", "17ea930e88b8802b330e1c1e288cdbaba52cbfafcccf371ed34b299a47101ffb", [: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", "07264a7225810ecae6bdd6715d8800c037a1248dc0063923cddc4ca3c4888df6"}, 8 - "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"}, 9 - "file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"}, 10 "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"}, 11 "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, 12 "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, 13 "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, 14 "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"}, 15 "makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"}, 16 "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, 17 "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"}, 18 "multiformats_ex": {:hex, :multiformats_ex, "0.2.0", "5b0a3faa1a770dc671aa8a89b6323cc20b0ecf67dc93dcd21312151fbea6b4ee", [:mix], [{:varint, "~> 1.4", [hex: :varint, repo: "hexpm", optional: false]}], "hexpm", "aa406d9addb06dc197e0e92212992486af6599158d357680f29f2d11e08d0423"}, 19 "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, 20 "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, 21 "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, 22 - "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"}, 23 - "recase": {:hex, :recase, "0.9.0", "437982693fdfbec125f11c8868eb3b4d32e9aa6995d3a68ac8686f3e2bf5d8d1", [:mix], [], "hexpm", "efa7549ebd128988d1723037a6f6a61948055aec107db6288f1c52830cb6501c"}, 24 - "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"}, 25 "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, 26 - "typedstruct": {:hex, :typedstruct, "0.5.3", "d68ae424251a41b81a8d0c485328ab48edbd3858f3565bbdac21b43c056fc9b4", [:make, :mix], [], "hexpm", "b53b8186701417c0b2782bf02a2db5524f879b8488f91d1d83b97d84c2943432"}, 27 "varint": {:hex, :varint, "1.5.1", "17160c70d0428c3f8a7585e182468cac10bbf165c2360cf2328aaa39d3fb1795", [:mix], [], "hexpm", "24f3deb61e91cb988056de79d06f01161dd01be5e0acae61d8d936a552f1be73"}, 28 }
··· 1 %{ 2 + "bandit": {:hex, :bandit, "1.10.0", "f8293b4a4e6c06b31655ae10bd3462f59d8c5dbd1df59028a4984f10c5961147", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "43ebceb7060a4d8273e47d83e703d01b112198624ba0826980caa3f5091243c4"}, 3 "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, 4 + "cldr_utils": {:hex, :cldr_utils, "2.29.1", "11ff0a50a36a7e5f3bd9fc2fb8486a4c1bcca3081d9c080bf9e48fe0e6742e2d", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:certifi, "~> 2.5", [hex: :certifi, repo: "hexpm", optional: true]}, {:decimal, "~> 1.9 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "3844a0a0ed7f42e6590ddd8bd37eb4b1556b112898f67dea3ba068c29aabd6c2"}, 5 + "con_cache": {:hex, :con_cache, "1.1.1", "9f47a68dfef5ac3bbff8ce2c499869dbc5ba889dadde6ac4aff8eb78ddaf6d82", [:mix], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "1def4d1bec296564c75b5bbc60a19f2b5649d81bfa345a2febcc6ae380e8ae15"}, 6 + "credo": {:hex, :credo, "1.7.15", "283da72eeb2fd3ccf7248f4941a0527efb97afa224bcdef30b4b580bc8258e1c", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "291e8645ea3fea7481829f1e1eb0881b8395db212821338e577a90bf225c5607"}, 7 "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, 8 + "dialyxir": {:hex, :dialyxir, "1.4.7", "dda948fcee52962e4b6c5b4b16b2d8fa7d50d8645bbae8b8685c3f9ecb7f5f4d", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b34527202e6eb8cee198efec110996c25c5898f43a4094df157f8d28f27d9efe"}, 9 "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, 10 + "erlex": {:hex, :erlex, "0.2.8", "cd8116f20f3c0afe376d1e8d1f0ae2452337729f68be016ea544a72f767d9c12", [:mix], [], "hexpm", "9d66ff9fedf69e49dc3fd12831e12a8a37b76f8651dd21cd45fcf5561a8a7590"}, 11 + "ex_cldr": {:hex, :ex_cldr, "2.44.1", "0d220b175874e1ce77a0f7213bdfe700b9be11aefbf35933a0e98837803ebdc5", [:mix], [{:cldr_utils, "~> 2.28", [hex: :cldr_utils, repo: "hexpm", optional: false]}, {:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:gettext, "~> 0.19 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: true]}], "hexpm", "3880cd6137ea21c74250cd870d3330c4a9fdec07fabd5e37d1b239547929e29b"}, 12 + "ex_doc": {:hex, :ex_doc, "0.39.3", "519c6bc7e84a2918b737aec7ef48b96aa4698342927d080437f61395d361dcee", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "0590955cf7ad3b625780ee1c1ea627c28a78948c6c0a9b0322bd976a079996e1"}, 13 + "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, 14 "finch": {:hex, :finch, "0.20.0", "5330aefb6b010f424dcbbc4615d914e9e3deae40095e73ab0c1bb0968933cadf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2658131a74d051aabfcba936093c903b8e89da9a1b63e430bee62045fa9b2ee2"}, 15 "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, 16 "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, 17 + "jose": {:hex, :jose, "1.11.12", "06e62b467b61d3726cbc19e9b5489f7549c37993de846dfb3ee8259f9ed208b3", [:mix, :rebar3], [], "hexpm", "31e92b653e9210b696765cdd885437457de1add2a9011d92f8cf63e4641bab7b"}, 18 "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, 19 "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, 20 "makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"}, 21 "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, 22 "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, 23 "multiformats_ex": {:hex, :multiformats_ex, "0.2.0", "5b0a3faa1a770dc671aa8a89b6323cc20b0ecf67dc93dcd21312151fbea6b4ee", [:mix], [{:varint, "~> 1.4", [hex: :varint, repo: "hexpm", optional: false]}], "hexpm", "aa406d9addb06dc197e0e92212992486af6599158d357680f29f2d11e08d0423"}, 24 + "mutex": {:hex, :mutex, "3.0.2", "528877fd0dbc09fc93ad667e10ea0d35a2126fa85205822f9dca85e87d732245", [:mix], [], "hexpm", "0a8f2ed3618160dca6a1e3520b293dc3c2ae53116265e71b4a732d35d29aa3c6"}, 25 "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, 26 "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, 27 "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, 28 + "peri": {:hex, :peri, "0.6.2", "3c043bfb6aa18eb1ea41d80981d19294c5e943937b1311e8e958da3581139061", [:mix], [{:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:stream_data, "~> 1.1", [hex: :stream_data, repo: "hexpm", optional: true]}], "hexpm", "5e0d8e0bd9de93d0f8e3ad6b9a5bd143f7349c025196ef4a3591af93ce6ecad9"}, 29 + "plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"}, 30 + "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, 31 + "recase": {:hex, :recase, "0.9.1", "82d2e2e2d4f9e92da1ce5db338ede2e4f15a50ac1141fc082b80050b9f49d96e", [:mix], [], "hexpm", "19ba03ceb811750e6bec4a015a9f9e45d16a8b9e09187f6d72c3798f454710f3"}, 32 + "req": {:hex, :req, "0.5.16", "99ba6a36b014458e52a8b9a0543bfa752cb0344b2a9d756651db1281d4ba4450", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "974a7a27982b9b791df84e8f6687d21483795882a7840e8309abdbe08bb06f09"}, 33 "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, 34 + "thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"}, 35 + "typedstruct": {:hex, :typedstruct, "0.5.4", "d1d33d58460a74f413e9c26d55e66fd633abd8ac0fb12639add9a11a60a0462a", [:make, :mix], [], "hexpm", "ffaef36d5dbaebdbf4ed07f7fb2ebd1037b2c1f757db6fb8e7bcbbfabbe608d8"}, 36 "varint": {:hex, :varint, "1.5.1", "17160c70d0428c3f8a7585e182468cac10bbf165c2360cf2328aaa39d3fb1795", [:mix], [], "hexpm", "24f3deb61e91cb988056de79d06f01161dd01be5e0acae61d8d936a552f1be73"}, 37 + "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, 38 }
+1
priv/templates/lexicon.eex
··· 1 defmodule <%= Atex.NSID.to_atom(lexicon["id"], false) %> do 2 use Atex.Lexicon 3 4 deflexicon(<%= inspect(lexicon, limit: :infinity, pretty: true, printable_limit: :infinity) %>)
··· 1 defmodule <%= Atex.NSID.to_atom(lexicon["id"], false) %> do 2 + @moduledoc false 3 use Atex.Lexicon 4 5 deflexicon(<%= inspect(lexicon, limit: :infinity, pretty: true, printable_limit: :infinity) %>)
+50
test/atex/oauth/permission_test.exs
···
··· 1 + defmodule Atex.OAuth.PermissionTest do 2 + use ExUnit.Case, async: true 3 + alias Atex.OAuth.Permission 4 + doctest Permission 5 + 6 + describe "account/1" do 7 + test "requires `:attr`" do 8 + assert_raise ArgumentError, ~r/`:attr` must be provided/, fn -> 9 + Permission.account() 10 + end 11 + end 12 + 13 + test "requires valid `:attr`" do 14 + assert_raise ArgumentError, ~r/`:attr` must be `:email` or `:repo`/, fn -> 15 + Permission.account(attr: :foobar) 16 + end 17 + 18 + assert Permission.account(attr: :email) 19 + end 20 + 21 + test "requires valid `:action`" do 22 + assert_raise ArgumentError, ~r/`:action` must be `:read`, `:manage`, or `nil`/, fn -> 23 + Permission.account(attr: :email, action: :foobar) 24 + end 25 + 26 + assert Permission.account(attr: :email, action: :manage) 27 + assert Permission.account(attr: :repo, action: nil) 28 + end 29 + end 30 + 31 + describe "rpc/2" do 32 + test "requires at least `:aud` or `:inherit_aud`" do 33 + assert_raise ArgumentError, ~r/must specify either/, fn -> 34 + Permission.rpc("com.example.getProfile") 35 + end 36 + end 37 + 38 + test "disallows `:aud` and `:inherit_aud` at the same time" do 39 + assert_raise ArgumentError, ~r/cannot specify both/, fn -> 40 + Permission.rpc("com.example.getProfile", aud: "example", inherit_aud: true) 41 + end 42 + end 43 + 44 + test "disallows wildcard for `lxm` and `aud` at the same time" do 45 + assert_raise ArgumentError, ~r/wildcard `lxm` and wildcard `aud`/, fn -> 46 + Permission.rpc("*", aud: "*") 47 + end 48 + end 49 + end 50 + end