atprotocrates

Gd.

Guides

Six tasks, each done twice: once from the terminal to find out whether it works, once in code to put it in a service.

§ 01

Resolve an identity

An AT Protocol identity has two names: a handle, which is a domain and can change, and a DID, which is permanent. Resolution goes handle → DID, and the DID document tells you where the account’s PDS lives and which key signs its commits.

resolve_subject accepts either form. Given a handle it queries DNS TXT at _atproto.<handle> and the HTTPS endpoint /.well-known/atproto-did and reconciles the two; given a DID it passes straight through.

Check it from the terminal firstListing 1
cargo run --features clap,hickory-dns \
  --bin atproto-identity-resolve -- alice.bsky.social
Handle to DID, then DID documentListing 2
use atproto_identity::resolve::{create_resolver, resolve_subject};

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let http_client = reqwest::Client::new();
    // Pass nameservers explicitly, or an empty slice for the system set.
    let dns_resolver = create_resolver(&[]);

    let did = resolve_subject(
        &http_client, &dns_resolver, "alice.bsky.social",
    ).await?;
    println!("did = {did}");

    Ok(())
}

Two sources, one answer

DNS and the well-known endpoint can disagree, and during a handle migration they routinely do. The resolver detects the conflict rather than silently preferring one; decide deliberately which your application trusts.

§ 02

Read a repository archive

A repository export is a CAR v1 file: a root CID followed by a bag of content-addressed blocks. Inside those blocks is a signed commit pointing at a Merkle Search Tree, and the tree’s leaves are the record CIDs.

Reading is streaming: CarReader hands you one block at a time, so a large export does not have to fit in memory.

List a CAR’s contents, then its treeListing 3
cargo run --package atproto-repo --features clap \
  --bin atproto-repo-car -- ls repo.car
cargo run --package atproto-repo --features clap \
  --bin atproto-repo-mst -- ls repo.car
Stream blocks out of an exportListing 4
use atproto_repo::car::CarReader;
use tokio::fs::File;

async fn read_car() -> anyhow::Result<()> {
    let file = File::open("repository.car").await?;
    let mut reader = CarReader::new(file).await?;

    // The roots are the commit CIDs this archive claims to carry.
    println!("roots: {:?}", reader.roots());

    while let Some(block) = reader.next_block().await? {
        println!("{} — {} bytes", block.cid, block.data.len());
    }

    Ok(())
}

To get an export in the first place, ask the account’s PDS for it. com.atproto.sync.getRepo streams the CAR, and ?since=<rev> narrows it to the blocks added after a revision you already hold.

§ 03

Sign a record

Attestation is CID-first. The record is prepared with $sig metadata, serialised to deterministic DAG-CBOR, hashed into a CID, and the CID bytes are what get signed. That makes the signing payload independent of JSON key order, whitespace, and every other thing that would otherwise make two encodings of the same record produce two different signatures.

An inline attestationListing 5
use atproto_attestation::{create_inline_attestation, AnyInput};
use atproto_identity::key::{generate_key, KeyType};
use serde_json::json;

fn main() -> anyhow::Result<()> {
    let key = generate_key(KeyType::P256Private)?;

    let record = json!({
        "$type": "app.bsky.feed.post",
        "text": "Hello AT Protocol!",
        "createdAt": "2024-01-01T00:00:00.000Z"
    });

    let metadata = json!({
        "$type": "com.example.inlineSignature",
        "key": "did:key:...",
        "issuer": "did:plc:issuer123",
        "issuedAt": "2024-01-01T00:00:00.000Z"
    });

    // The repository DID is bound into $sig. This is the replay defence.
    let signed = create_inline_attestation(
        AnyInput::Serialize(record),
        AnyInput::Serialize(metadata),
        "did:plc:repo123",
        &key,
    )?;

    println!("{}", serde_json::to_string_pretty(&signed)?);
    Ok(())
}

Low-S normalisation

ECDSA signatures come in pairs: for every valid (r, s) there is an equally valid (r, n − s). Without normalisation an attacker can flip one into the other and produce a second, different, still-valid signature over the same record. This crate normalises to low-S on the way out. If you verify these signatures elsewhere, make sure that verifier does too.

Sign and verify from the terminalListing 6
cargo run --package atproto-attestation --features clap,tokio \
  --bin atproto-attestation-sign -- \
  inline record.json did:key:... metadata.json

cargo run --package atproto-attestation --features clap,tokio \
  --bin atproto-attestation-verify -- signed_record.json

§ 04

Complete an OAuth flow

AT Protocol OAuth is OAuth 2.1 with three additions that are not optional: the authorization request is pushed server-to-server first (PAR, RFC 9126), the code exchange is PKCE-protected (RFC 7636), and every token is bound to a key you hold (DPoP, RFC 9449). All three live in atproto-oauth, module workflow.

Your client authenticates with a signed assertion rather than a shared string. OAuthClient holds a private_signing_key_data, not a client secret, and a second key — the DPoP key — is passed separately and binds the tokens you receive.

Discovery, then push, then exchangeListing 7
use atproto_identity::key::identify_key;
use atproto_oauth::resources::pds_resources;
use atproto_oauth::workflow::{
    oauth_complete, oauth_init, OAuthClient, OAuthRequestState,
};

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let http_client = reqwest::Client::new();

    // Two distinct keys: one signs client assertions, one binds tokens.
    let oauth_client = OAuthClient {
        redirect_uri: "https://your-app.com/callback".to_string(),
        client_id:    "https://your-app.com/client-metadata.json"
            .to_string(),
        private_signing_key_data: identify_key("did:key:zQ3sh...")?,
    };
    let dpop_key = identify_key("did:key:zDna...")?;

    // Ask the user's PDS which authorization server speaks for it.
    let (_resource, auth_server) =
        pds_resources(&http_client, "https://pds.example.com").await?;

    let request_state = OAuthRequestState {
        state:          "random-state".to_string(),
        nonce:          "random-nonce".to_string(),
        code_challenge: "code-challenge".to_string(),
        scope:          "atproto transition:generic".to_string(),
    };

    // 1 — Pushed Authorization Request. Redirect using request_uri.
    let par = oauth_init(
        &http_client,
        &oauth_client,
        &dpop_key,
        Some("alice.bsky.social"),
        &auth_server,
        &request_state,
    ).await?;
    println!("{} (expires in {}s)", par.request_uri, par.expires_in);

    // 2 — The user returns with a code. expected_subject is the DID the
    //     flow began for; a token claiming anyone else is rejected.
    let tokens = oauth_complete(
        &http_client,
        &oauth_client,
        &dpop_key,
        "received-auth-code",
        "did:plc:theuser",
        &oauth_request,
        &auth_server,
    ).await?;

    println!("{}", tokens.access_token);
    Ok(())
}

The subject check is the interesting part

oauth_complete refuses a token whose sub claim does not equal the expected_subject you pass. That is not a formality — a mismatch means the authorisation server returned a token for a different account than the one the flow started for, and a client that skips the comparison will happily log somebody in as the wrong person.

Pass a DID, never a handle. OAuthRequest::subject documents the same requirement: the comparison is exact, and a handle will never equal a sub.

Refreshing takes the account’s DID document rather than an endpoint, because it re-runs discovery itself and pins the subject to document.id:

Rotate a tokenListing 8
use atproto_oauth::workflow::oauth_refresh;

let tokens = oauth_refresh(
    &http_client,
    &oauth_client,
    &dpop_key,
    &stored_refresh_token,
    &did_document,
).await?;

Expect a nonce challenge

An authorisation server may reject your first DPoP proof with use_dpop_nonce and supply the nonce it wants. That is the protocol working, not a failure. Hand-rolled clients that treat the first 400 as terminal will appear to work against some servers and not others.

If you are serving the client side in Axum, atproto-oauth-axum has the callback, JWKS and client-metadata handlers. If your service issues its own inter-service tokens instead, that is atproto-xrpcs.

§ 05

Write a record

Publishing is one XRPC call. Four things have to be right before you make it: which repository (a DID), which collection (an NSID), which record key, and which host to send it to.

That last one is the part people hard-code and regret. A PDS endpoint is not a constant — it is a property of the account, published in its DID document, and it moves when the account migrates. Resolve it every time.

Publish one record from the terminalListing 9
export ATPROTO_PASSWORD=<app-password>

cargo run --features clap --bin atproto-client-put-record -- \
  alice.bsky.social 3kabcdefghij '{
    "$type": "app.bsky.feed.post",
    "text": "hello",
    "createdAt": "2026-01-01T00:00:00.000Z"
  }'

The tool takes a handle or a DID, a record key, and the record itself. It reads the collection out of the record’s $type, resolves the subject to a DID, pulls the PDS endpoint from the DID document, opens a session, and writes. That sequence is the same one you write in code.

Resolve the endpoint, then create the recordListing 10
use atproto_client::client::{Auth, DPoPAuth};
use atproto_client::com::atproto::repo::{
    create_record, CreateRecordRequest, CreateRecordResponse,
};
use atproto_identity::key::identify_key;
use atproto_record::tid::Tid;
use serde_json::json;

async fn publish(
    http_client: &reqwest::Client,
    document: &atproto_identity::model::Document,
    access_token: &str,
) -> anyhow::Result<()> {
    // The endpoint comes from the account's own DID document.
    let endpoints = document.pds_endpoints();
    let pds = endpoints.first().ok_or_else(|| anyhow::anyhow!("no PDS"))?;

    // Tokens from the OAuth flow are DPoP-bound: the proof key travels
    // with every request, not just the token exchange.
    let auth = Auth::DPoP(DPoPAuth {
        dpop_private_key_data: identify_key("did:key:zDna...")?,
        oauth_access_token: access_token.to_string(),
    });

    let request = CreateRecordRequest {
        repo:       document.id.clone(),
        collection: "app.bsky.feed.post".to_string(),
        record_key: Some(Tid::new().to_string()),
        validate:   true,
        record: json!({
            "$type": "app.bsky.feed.post",
            "text": "hello",
            "createdAt": "2026-01-01T00:00:00.000Z"
        }),
        swap_commit: None,
    };

    match create_record(http_client, &auth, pds, request).await? {
        CreateRecordResponse::StrongRef { uri, cid, .. } => {
            println!("{uri} @ {cid}");
        }
        CreateRecordResponse::Error(err) => {
            // Reached the server; the server said no.
            anyhow::bail!("createRecord refused: {err:?}");
        }
    }
    Ok(())
}

A refusal is not an Err

CreateRecordResponse is an untagged enum with two variants, and a server that rejects your write returns Ok(CreateRecordResponse::Error(..)) — not Err. The ? operator will not catch it.

Code that matches only StrongRef and waves the rest through with _ => unreachable!() compiles, passes a happy-path test, and silently treats every validation failure and every permission error as success. Match both variants. PutRecordResponse and DeleteRecordResponse have the same shape.

createRecord and putRecord differ in who owns the key. Omit record_key from a create and the server generates one, which is what you want for append-only collections like posts. putRecord requires a key and writes at it, so re-running it with the same key updates rather than duplicates — the right call for records with a natural identity, like a profile.

Choosing between them
FieldcreateRecordputRecord
record_keyOption — server generates one when absent.Required.
Re-running itWrites another record.Overwrites the one at that key.
swap_commitAvailable.Available.
swap_recordAvailable.

validate: true asks the PDS to check the record against its lexicon before committing. Leave it on. A record that satisfies the schema is not automatically one the rest of the ecosystem will accept, but a record that fails it will not be accepted by anything.

§ 06

Consume the event stream

Jetstream is a JSON view of the firehose, filtered server-side and optionally Zstandard-compressed. You register handlers, hand the consumer a cancellation token, and it reconnects on its own.

A handler, a config, a consumerListing 11
use async_trait::async_trait;
use atproto_jetstream::{
    CancellationToken, Consumer, ConsumerTaskConfig,
    EventHandler, JetstreamEvent,
};

struct MyEventHandler;

#[async_trait]
impl EventHandler for MyEventHandler {
    async fn handle_event(
        &self,
        event: JetstreamEvent,
    ) -> anyhow::Result<()> {
        println!("{event:?}");
        Ok(())
    }

    fn handler_id(&self) -> String {
        "my-handler".to_string()
    }
}

let config = ConsumerTaskConfig {
    user_agent: "my-app/1.0".to_string(),
    compression: false,
    zstd_dictionary_location: String::new(),
    jetstream_hostname: "jetstream1.us-east.bsky.network".to_string(),
    collections: vec!["app.bsky.feed.post".to_string()],
};

let consumer = Consumer::new(config);
consumer.register_handler(std::sync::Arc::new(MyEventHandler)).await?;

// Cancel the token to drain cleanly instead of dropping the socket.
let cancel = CancellationToken::new();
consumer.run_background(cancel).await?;

Compression needs Bluesky’s published dictionary on disk; point zstd_dictionary_location at it and set compression: true.

Fetch the dictionaryListing 12
curl -o data/zstd_dictionary \
  https://github.com/bluesky-social/jetstream/raw/refs/heads/main/pkg/models/zstd_dictionary

§ 07

Where next

  • Concepts — what this workspace means by CID, DRISL, MST, TID, DPoP and NSID.
  • Tools — every binary in the workspace, with the feature flags it needs.
  • PDS — running atproto-pds, and what it does not yet do.