Packages

A collection of ATProto clients and utilities, including XRPC clients, firehose consumers, and more.

Current section

Files

Jump to
exosphere README.md
Raw

README.md

# Exosphere
[![Hex.pm](https://img.shields.io/hexpm/v/exosphere.svg)](https://hex.pm/packages/exosphere)
[![HexDocs](https://img.shields.io/badge/docs-hexdocs-blue.svg)](https://hexdocs.pm/exosphere)
![Logo](./docs/static/banner.png)
Exosphere is a collection of AT Protocol clients and utilities.
## Documentation
- **HexDocs**: https://hexdocs.pm/exosphere
## What’s inside
- `Exosphere.ATProto.*`: lower-level, spec-aligned implementation building blocks (see [atproto.com](https://atproto.com/))
- `Exosphere.*`: public-facing API modules built on top of `Exosphere.ATProto.*` (XRPC client, OAuth session, firehose consumer, etc.)
## Getting started
### Installation
Add `exosphere` to your dependencies:
```elixir
def deps do
[
{:exosphere, "~> 0.5"}
]
end
```
### Quickstart: XRPC client
`Exosphere.XRPC.Client` is a small wrapper around `Exosphere.ATProto.XRPC.Client`.
```elixir
# Create an unauthenticated client for a PDS
client = Exosphere.XRPC.Client.new("https://bsky.social")
{:ok, %{"did" => did}} =
Exosphere.XRPC.Client.query(client, "com.atproto.identity.resolveHandle",
handle: "atproto.com"
)
```
## OAuth (DPoP-bound sessions)
`Exosphere.ATProto.OAuth.*` implements the full [ATProto OAuth
profile](https://atproto.com/specs/oauth): identity-to-server discovery,
client metadata documents, PAR, PKCE, `private_key_jwt`, DPoP-bound tokens
with nonce handling, token exchange, and rotating refresh tokens.
`Exosphere.OAuth.Session` wraps the result in a GenServer that keeps the
session fresh and signs XRPC calls.
```elixir
alias Exosphere.ATProto.OAuth.{Client, ClientMetadata, Discovery, Flow, JWK}
client = Client.new!(
metadata: ClientMetadata.new!(
client_id: "https://app.example.com/oauth-client-metadata.json",
client_name: "My App",
redirect_uris: ["https://app.example.com/oauth/callback"],
scope: ["atproto", "transition:generic"],
jwk: JWK.to_public(client_key)
),
key: client_key,
redirect_uri: "https://app.example.com/oauth/callback"
)
{:ok, resolved} = Discovery.resolve("alice.example.com")
{:ok, {authorize_url, ctx}} = Flow.authorize_url(client, resolved)
# ... browser round-trip; store ctx server-side ...
{:ok, session} = Flow.callback(ctx, callback_params)
{:ok, pid} = Exosphere.OAuth.Session.start_link(session: session)
{:ok, profile} = Exosphere.OAuth.Session.query(pid, "app.bsky.actor.getProfile", actor: session.sub)
```
See the [OAuth guide](oauth.html) for the complete walk-through, including
local-development loopback clients and the in-process mock PDS for e2e
testing.
## Firehose (subscribeRepos)
Use `Exosphere.Firehose.Consumer` to connect to a relay’s
`com.atproto.sync.subscribeRepos` WebSocket endpoint, decode frames into
structured messages, and dispatch them to your callback.
### Running under a supervisor
The consumer **requires** an `:on_event` callback with arity 2: `(message, state) -> state`.
```elixir
children = [
{Exosphere.Firehose.Consumer,
relay_url: "wss://bsky.network",
cursor: nil,
on_event: &MyApp.Firehose.on_event/2,
name: MyApp.FirehoseConsumer}
]
Supervisor.start_link(children, strategy: :one_for_one)
```
### Handling events
Messages are decoded into maps with a `:type` key (for example `:commit`, `:identity`, `:handle`).
For commit messages, you can extract record data from the embedded CAR blocks via
`Exosphere.ATProto.Firehose.Message.extract_records/1`.
```elixir
defmodule MyApp.Firehose do
require Logger
alias Exosphere.ATProto.Firehose.Message
def on_event(%{type: :commit} = msg, state) do
# Persist msg.seq somewhere if you want resumable consumption (cursor).
case Message.extract_records(msg) do
{:ok, records} ->
Logger.info("commit seq=#{msg.seq} records=#{length(records)}")
state
{:error, reason} ->
Logger.warning("commit seq=#{msg.seq} extract_records failed: #{inspect(reason)}")
state
end
end
def on_event(msg, state) do
Logger.debug("firehose event: #{inspect(msg.type)}")
state
end
end
```
## Verifying repositories
You don't have to trust a PDS's word for what's in a repository. Exosphere can
fetch a full repository archive and prove it against the key the account
advertises in its DID document:
```elixir
{:ok, %{rev: rev, records: records}} =
Exosphere.ATProto.Repo.verify_checkout("https://bsky.network", "did:plc:abc123")
```
That one call downloads `com.atproto.sync.getRepo`, reads every record out of
the Merkle Search Tree, confirms the record set matches the commit's signed
root, resolves the DID document, and verifies the commit signature. If it
returns `{:ok, _}`, the records provably come from the account controlling
that DID.
For firehose events, `Exosphere.ATProto.Firehose.Message.verify_commit/1`
checks a `#commit` message's embedded blocks against its signed MST root —
see the [Firehose guide](firehose.html) for when that succeeds (incremental
CARs only carry new blocks) and how to build on it.
## Lexicons: register, type-check, and publish
Exosphere ships compile-time typed modules for the vendored bsky/community
lexicons, and a runtime workflow for lexicons of your own — or anyone else's.
See the [Lexicons guide](lexicons.html) for the full walkthrough: authoring,
linting, validation semantics, publishing and safe modification, resolution,
and host-app code generation.
Define (or fetch) a lexicon, type-check records against it, and publish it to
a PDS as a `com.atproto.lexicon.schema` record:
```elixir
{:ok, schema} = Exosphere.Lexicon.Schema.new(%{
"lexicon" => 1,
"id" => "com.example.post",
"defs" => %{"main" => %{
"type" => "record", "key" => "tid",
"record" => %{"type" => "object",
"required" => ["text"],
"properties" => %{"text" => %{"type" => "string", "maxGraphemes" => 100}}}
}}
})
# Type-check records at runtime (spec semantics: unknown fields ignored,
# open unions, byte-vs-grapheme string limits; pass strict: true to reject)
:ok = Exosphere.Lexicon.register(schema)
:ok = Exosphere.Lexicon.validate("com.example.post", %{
"$type" => "com.example.post", "text" => "hello"
})
# Publish: record key is the NSID, so it lives at
# at://<did>/com.atproto.lexicon.schema/com.example.post
{:ok, %{uri: uri, cid: cid}} =
Exosphere.Lexicon.publish(session, pds_url, did, schema)
```
Lexicons published by any repository can be fetched back and registered:
```elixir
# From a known repo
{:ok, schema} =
Exosphere.Lexicon.Resolver.fetch(pds_url, did, "com.example.post", register: true)
# Or every lexicon a repo publishes
{:ok, %{schemas: schemas}} = Exosphere.Lexicon.Resolver.list(pds_url, did)
# Or via NSID authority (DNS TXT _lexicon.<domain> → DID → PDS)
{:ok, schema} = Exosphere.Lexicon.Resolver.resolve("com.example.post")
```
To go back to compile-time safety, vendor a repo's lexicons and generate
typed modules for them:
```console
$ mix exosphere.gen.lexicons --from did:plc:abc123
```
Before publishing, lint your lexicon documents against the spec rules
(spec errors plus style-guide warnings for missing descriptions):
```console
$ mix exosphere.lint.lexicons my_lexicon.json
```
## Notes
- The consumer **reconnects automatically** on disconnects and errors,
re-subscribing at the last cursor it tracked (with capped, jittered
backoff between attempts).
- For more control (or lower-level access), use the `Exosphere.ATProto.*` modules directly.
## CI / Releases
This project uses GitHub Actions:
- **CI**: runs `mix format --check-formatted`, `mix credo --strict`, `mix test`, and `mix dialyzer` on pushes + PRs.
- **Auto-versioning on merge**: when a PR is merged into `main`, a workflow requires exactly one label: `major`, `minor`, or `patch`. It bumps `mix.exs`, commits, tags `vX.Y.Z`, and pushes (which triggers the Hex release workflow).
- **Release**: pushing a tag like `v0.1.0` publishes the package + docs to Hex.
To enable publishing, add a repository secret named `HEX_API_KEY` (generate one via `mix hex.user key generate`).