Exosphere

Hex.pmHexDocs

Logo

Exosphere is a collection of AT Protocol clients and utilities.

Documentation

What’s inside

Getting started

Installation

Add exosphere to your dependencies:

def deps do
[
{:exosphere, "~> 0.5"}
]
end

Quickstart: XRPC client

Exosphere.XRPC.Client is a small wrapper around Exosphere.ATProto.XRPC.Client.

# 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: 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.

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 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.

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.

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:

{: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 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 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:

{: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:

# 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:

$ 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):

$ mix exosphere.lint.lexicons my_lexicon.json

Notes

CI / Releases

This project uses GitHub Actions:

To enable publishing, add a repository secret named HEX_API_KEY (generate one via mix hex.user key generate).