Fork of atproto-crates for SCN forked from ngerakines.me/atproto-crates
atproto pds permissioned-data rust

Files
README.md

AT Protocol Rust Crates #

A Rust workspace of crates for building AT Protocol applications. This collection provides building blocks for content-addressed data, identity management, repository operations, record signing, OAuth 2.0 authentication flows, HTTP client operations, XRPC services, real-time event streaming, and developer tooling.

Origin: Parts of this project were extracted from the open-source smokesignal.events project, an AT Protocol event and RSVP management application. This library is released under the MIT license to enable broader AT Protocol ecosystem development.

Components #

This workspace publishes 17 crates that work together to provide complete AT Protocol application development capabilities. Which binaries each one ships, and how to run them, is in Command Line Tools.

Data Foundations #

  • atproto-dasl - DASL (Data-Addressed Structures & Links) framework implementing CID computation, DRISL (deterministic DAG-CBOR encoding), CAR v1 archives, block storage backends, RASL retrieval, BDASL large file hashing, and Web Tiles.

Identity & Cryptography #

  • atproto-identity - Core identity management with DID resolution for the did:plc and did:web methods, DNS/HTTP handle resolution, PLC directory operations, and P-256, K-256 and Ed25519 cryptographic operations.
  • atproto-attestation - CID-first attestation utilities for creating and verifying cryptographic signatures on AT Protocol records, supporting both inline and remote attestation workflows.
  • atproto-record - Record types and the encodings records are built from: TID generation, AT-URI, NSID and record-key parsing, datetime formatting, $bytes handling, and the community lexicon types. It computes no CIDs; a record's CID is atproto_dasl::compute_cid_from_json in atproto-dasl.
  • atproto-lexicon - Lexicon schemas for AT Protocol, in two halves: a validator that checks records, query parameters and procedure bodies against a schema catalog, and DNS-based resolution that fetches those schemas by NSID. The validator is available with default-features = false, without an HTTP client or DNS resolver.

Repository #

  • atproto-repo - AT Protocol repository handling with Merkle Search Tree (MST) encoding/decoding, commit structures, tree diffing for sync, and configurable verification. Builds on atproto-dasl for CAR and storage.

Authentication & Authorization #

  • atproto-oauth - OAuth 2.0 with AT Protocol security extensions: DPoP (RFC 9449), PKCE (RFC 7636), JWT operations, and secure storage abstractions. Its workflow module serves confidential clients only — every authorization-server request carries a private_key_jwt assertion — while pkce, dpop, jwt, jwk, resources and scopes are useful to any client, and to an authorization server.

Client & Service Development #

  • atproto-client - HTTP client library supporting multiple authentication methods (DPoP, Bearer tokens, sessions) with native XRPC protocol operations and repository management.
  • atproto-xrpcs - Minting and verifying service-auth tokens, the credential one AT Protocol service presents to another, against a policy naming the method and audience the call actually is. It is a pair of functions and a policy type: there is no middleware and there are no extractors.
  • atpxrpc - XRPC CLI client with persistent session management for making authenticated AT Protocol API calls.

Atproto Spaces (permissioned data) #

  • atproto-space - Primitives for the 0016 Atproto Spaces draft: SetHash commitments, signed space commits, the delegation-token and space-credential JWTs (ten minutes by default, an hour at most), the RFC 9421 HTTP Message Signatures that bind a credential to its holder's key, and repo CAR serialization and verification for the space sync surface.
  • atproto-space-client - XRPC client for permissioned-data spaces: the 0016 credential exchange with signed requests, client attestation, space management, the record and sync surfaces, following a space's sequenced writer set from a checkpoint, and credential revocation.

Real-time Event Processing #

  • atproto-firehose - com.atproto.sync.subscribeRepos consumer: frame decoding, commit verification, and a reconnecting stream.
  • atproto-jetstream - Jetstream v2 consumer: filtered, pre-decoded events over one WebSocket, with cursor resumption, reconnection and dict-zstd compression.
  • atproto-tap - Tap (Bluesky's atproto sync utility) service consumer for filtered, verified AT Protocol repository events, with acknowledgment-based delivery; MST integrity checks and backfill are the Tap service's own work, not this client's.

Developer Tools #

  • atpmcp - An MCP (Model Context Protocol) server speaking the 2026-07-28 revision over stdio, exposing twelve tools — CIDs, TIDs, facets, identity resolution, record and lexicon fetches, XRPC validation and invocation, and blob transfer — plus blobs as resources. Two of the twelve write, using app-password sessions shared with atpxrpc; invoke_xrpc calls whatever NSID it is given and is marked destructive.

Utilities #

  • atproto-extras - Facet parsing and rich text utilities for AT Protocol, extracting mentions, URLs, and hashtags from plain text with correct UTF-8 byte offset calculation.

atproto-oauth-dioxus, the Dioxus fullstack integration for the OAuth flow, is in this repository but not a member of this workspace and does not ship with these releases. It is unmaintained and does not currently build: its manifest inherits zeroize from the workspace's [workspace.dependencies], which no longer lists it, so Cargo refuses to parse the manifest.

atproto-pds, a Personal Data Server built from the crates above — the accounts database and per-actor stores, the OAuth authorization server, the account portal with passkeys, the account-migration wizard and the space host — is a workspace member, built and tested with the rest, but it is not published. Neither the crate nor its pds and atproto-pds-admin binaries go to crates.io: build them from this repository, or from the container images described in its README.

Quick Start #

This is exactly what the five examples below need, and nothing else — add the rest from Components as you reach for them:

[dependencies]
atproto-attestation = "0.15.0-rc.6"
atproto-client = "0.15.0-rc.6"
atproto-identity = "0.15.0-rc.6"
atproto-lexicon = "0.15.0-rc.6"
atproto-oauth = "0.15.0-rc.6"
atproto-xrpcs = "0.15.0-rc.6"
axum = "0.8"
chrono = "0.4"
serde_json = "1"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }

reqwest is deliberately not in that list. Every crate here whose API takes a reqwest type re-exports the reqwest it was compiled against — atproto_identity::reqwest, atproto_lexicon::reqwest, atproto_oauth::reqwest, atproto_client::reqwest and the rest — and takes a client of that one. A reqwest from an incompatible release line in your own Cargo.toml, 0.12 against this workspace's 0.13, is a different crate with a different Client type, and the mismatch reads as "expected Client, found Client". Construct clients through the re-export, as every example below does, and the versions never have to be matched by hand.

Basic Identity Resolution #

Resolving a handle or a did:web fetches from a host the identifier names, so from whoever supplied it. The crate checks the URL it builds, and that covers the first request only: the client has to refuse redirects, or check every hop, since reqwest's default follows them to http:// and address literals. The examples here use Policy::none(), the simple, safe choice; it fails a handle whose /.well-known/atproto-did redirects, which the handle specification allows, and atproto_identity::redirect::validated_policy(), which follows at most five hops and checks each with validate_service_endpoint, resolves those as well. Response bodies are read under fixed ceilings of the crate's own, so a host cannot choose how much is held. atproto-identity's crate docs, under "The HTTP client you pass in", show both policies and give the ceilings.

use std::time::Duration;

use atproto_identity::reqwest::{Client, redirect::Policy};
use atproto_identity::resolve::{HickoryDnsResolver, resolve_subject};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let http_client = Client::builder()
        .redirect(Policy::none())
        .timeout(Duration::from_secs(10))
        .build()?;

    // An empty slice means the system resolvers. Pass addresses to override
    // them: `create_resolver(&["1.1.1.1".parse()?])`.
    let dns_resolver = HickoryDnsResolver::create_resolver(&[]);

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

    Ok(())
}

Lexicon Resolution #

use std::sync::Arc;

use atproto_identity::resolve::{HickoryDnsResolver, InnerIdentityResolver};
use atproto_lexicon::reqwest::{Client, redirect::Policy};
use atproto_lexicon::resolve::{DefaultLexiconResolver, LexiconResolver};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Refuses redirects, as in Basic Identity Resolution above.
    let http_client = Client::builder().redirect(Policy::none()).build()?;
    let dns_resolver = Arc::new(HickoryDnsResolver::create_resolver(&[]));

    // The identity resolver names the PLC directory lexicon authorities are
    // resolved against; point it at your own by changing `plc_hostname`.
    let identity_resolver = Arc::new(InnerIdentityResolver {
        dns_resolver: dns_resolver.clone(),
        http_client: http_client.clone(),
        plc_hostname: "plc.directory".to_string(),
    });
    let resolver = DefaultLexiconResolver::new(http_client, dns_resolver, identity_resolver);

    // Resolve a lexicon schema
    let lexicon = resolver.resolve("app.bsky.feed.post").await?;
    println!("Lexicon schema: {}", serde_json::to_string_pretty(&lexicon)?);

    Ok(())
}

Record Signing #

use atproto_attestation::{AnyInput, create_inline_attestation};
use atproto_identity::key::{KeyType, generate_key, to_public};
use serde_json::json;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    // A deployed service loads its key from configuration with
    // `identify_key("did:key:z...")`; generating one keeps this runnable.
    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"
    });

    // `key` is the *public* form of the key that signs, so a verifier can find
    // it. A placeholder here is not a cosmetic problem: signing reads this
    // field and fails on anything that is not a real did:key.
    let metadata = json!({
        "$type": "com.example.inlineSignature",
        "key": to_public(&key)?.to_string(),
        "issuer": "did:plc:issuer123",
        "issuedAt": "2024-01-01T00:00:00.000Z"
    });

    // The repository DID is bound into the signature, so a signed record
    // lifted into another repository does not verify there.
    let signed_record = create_inline_attestation(
        AnyInput::Serialize(record),
        AnyInput::Serialize(metadata),
        "did:plc:repo123",
        &key,
    )?;

    println!("Signed record: {}", serde_json::to_string_pretty(&signed_record)?);
    Ok(())
}

XRPC Service #

An XRPC method that accepts inter-service calls verifies the bearer token against a policy naming the method it serves and the audience it answers to. The policy is what makes a token minted for another method, or for another service, fail here — a signature check on its own does not.

use std::sync::Arc;

use atproto_identity::resolve::{HickoryDnsResolver, InnerIdentityResolver};
use atproto_identity::reqwest::{Client, redirect::Policy};
use atproto_identity::traits::IdentityResolver;
use atproto_xrpcs::{ServiceAuthPolicy, verify_service_auth};
use axum::extract::State;
use axum::http::{HeaderMap, StatusCode};
use axum::{Json, Router, routing::get};
use serde_json::json;

/// This service's own identity, and the resolver it checks callers with.
#[derive(Clone)]
struct AppState {
    service_did: String,
    resolver: Arc<dyn IdentityResolver>,
}

async fn handle_hello(
    State(state): State<AppState>,
    headers: HeaderMap,
) -> Result<Json<serde_json::Value>, StatusCode> {
    let token = headers
        .get("authorization")
        .and_then(|value| value.to_str().ok())
        .and_then(|value| value.strip_prefix("Bearer "))
        .ok_or(StatusCode::UNAUTHORIZED)?;

    // `lxm` is the method being served and `aud` is this service. Verification
    // returns the claims or an error; there is no third state in which an
    // unverified caller reaches the handler.
    let policy = ServiceAuthPolicy::new("com.example.hello", &state.service_did);
    let claims = verify_service_auth(token, state.resolver.as_ref(), &policy, None)
        .await
        .map_err(|_| StatusCode::UNAUTHORIZED)?;

    Ok(Json(json!({ "message": format!("Hello, {}!", claims.iss) })))
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // A caller's token names the DID resolved here, so its did:web host is
    // theirs to choose: the client refuses redirects.
    let http_client = Client::builder().redirect(Policy::none()).build()?;
    let dns_resolver = Arc::new(HickoryDnsResolver::create_resolver(&[]));

    let state = AppState {
        service_did: "did:web:service.example".to_string(),
        resolver: Arc::new(InnerIdentityResolver {
            dns_resolver,
            http_client,
            plc_hostname: "plc.directory".to_string(),
        }),
    };

    let app = Router::new()
        .route("/xrpc/com.example.hello", get(handle_hello))
        .with_state(state);

    let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await?;
    axum::serve(listener, app).await?;

    Ok(())
}

Minting the other side of that call is atproto_xrpcs::mint_service_auth.

OAuth Client Flow #

This is a confidential client: OAuthClient carries a private signing key, and every authorization-server request is authenticated with a private_key_jwt assertion made from it. A browser SPA or a native app cannot keep that key from its user, so it cannot drive this flow directly — run it in a backend that holds the key.

The flow is two HTTP requests in your application, not one: the first serves the sign-in link, the second handles the redirect back. What carries between them is an OAuthRequest, which you store keyed by its state — so the two halves are two functions here.

The state is not a CSRF defence on its own. The library sends it and never checks it, and a store keyed by state alone will find the request behind any callback URL, including one an attacker began for their own account and sent to someone else. Two things make it one, and both are yours to do:

  • On the redirect out, set the same value in an HttpOnly; Secure; SameSite=Lax cookie.
  • On the callback, refuse unless that cookie equals the query's state. Then load the request and delete it before exchanging the code, so it is used once.
use atproto_identity::key::{KeyType, generate_key, identify_key, to_public};
use atproto_identity::traits::IdentityResolver;
use atproto_oauth::pkce;
use atproto_oauth::reqwest::Client;
use atproto_oauth::resources::{oauth_authorization_server, pds_resources};
use atproto_oauth::workflow::{
    OAuthClient, OAuthRequest, OAuthRequestState, TokenResponse, generate_state, oauth_complete,
    oauth_init,
};

type Error = Box<dyn std::error::Error>;

/// An account's DID and PDS, from its DID document. The endpoint is checked
/// against the service-endpoint policy before anything is fetched from it.
async fn did_and_pds(
    resolver: &dyn IdentityResolver,
    subject: &str,
) -> Result<(String, String), Error> {
    let document = resolver.resolve(subject).await?;
    let pds = document
        .pds_endpoints_validated()
        .first()
        .ok_or("the DID document names no usable PDS")?
        .to_string();
    Ok((document.id, pds))
}

/// First request: resolve the account, push the authorization request, and
/// build what the callback will need.
///
/// Returns the URL to send the browser to, and the `OAuthRequest` to store under
/// its `oauth_state`. Set that state in the cookie on the same redirect.
async fn begin_sign_in(
    http_client: &Client,
    resolver: &dyn IdentityResolver,
    oauth_client: &OAuthClient,
    handle: &str,
) -> Result<(String, OAuthRequest), Error> {
    // The flow is pinned to a DID, never a handle: `oauth_complete` requires
    // the token endpoint's `sub` to equal it exactly.
    let (did, pds) = did_and_pds(resolver, handle).await?;
    let (_protected_resource, authorization_server) = pds_resources(http_client, &pds).await?;

    // All three are new for every sign-in. The verifier stays here and only
    // its challenge goes on the wire; the DPoP key binds this flow's tokens.
    let (pkce_verifier, code_challenge) = pkce::generate();
    let dpop_key = generate_key(KeyType::P256Private)?;
    let request_state = OAuthRequestState {
        state: generate_state(),
        code_challenge,
        scope: "atproto transition:generic".to_string(),
    };

    let par_response = oauth_init(
        http_client,
        oauth_client,
        &dpop_key,
        Some(handle),
        &authorization_server,
        &request_state,
    )
    .await?;

    let authorization_url = format!(
        "{}?client_id={}&request_uri={}",
        authorization_server.authorization_endpoint,
        oauth_client.client_id,
        par_response.request_uri
    );

    let now = chrono::Utc::now();
    let oauth_request = OAuthRequest {
        oauth_state: request_state.state,
        subject: did,
        issuer: authorization_server.issuer.clone(),
        pkce_verifier,
        signing_public_key: to_public(&oauth_client.private_signing_key_data)?.to_string(),
        dpop_private_key: dpop_key.to_string(),
        created_at: now,
        expires_at: now + chrono::Duration::seconds(par_response.expires_in as i64),
    };

    Ok((authorization_url, oauth_request))
}

/// Second request: the callback, carrying `?code=…&state=…&iss=…`.
///
/// By the time this runs the callback has passed the cookie check, and
/// `oauth_request` has been loaded by its `state` and deleted from storage.
async fn finish_sign_in(
    http_client: &Client,
    oauth_client: &OAuthClient,
    oauth_request: &OAuthRequest,
    code: &str,
    callback_issuer: Option<&str>,
) -> Result<TokenResponse, Error> {
    // The authorization server again, fetched from the issuer the flow began
    // with. Not rediscovered from the account's PDS: that finds whichever
    // server it names now, and `oauth_complete` refuses metadata whose issuer
    // is not `oauth_request.issuer`.
    let authorization_server = oauth_authorization_server(http_client, &oauth_request.issuer).await?;

    // The same DPoP key the flow began with, read back out of the request.
    let dpop_key = identify_key(&oauth_request.dpop_private_key)?;

    // Two checks happen in here. The callback's `iss` must be the issuer the
    // flow began with (RFC 9207), and the token endpoint's `sub` must be the
    // DID it began for. Neither is the `state` check above.
    let token_response = oauth_complete(
        http_client,
        oauth_client,
        &dpop_key,
        code,
        &oauth_request.subject,
        callback_issuer,
        oauth_request,
        &authorization_server,
    )
    .await?;

    Ok(token_response)
}

fn configure() -> Result<OAuthClient, Error> {
    Ok(OAuthClient {
        redirect_uri: "https://your-app.example/callback".to_string(),
        // An atproto `client_id` is the URL its metadata document is served at.
        client_id: "https://your-app.example/client-metadata.json".to_string(),
        // Loaded from configuration in a deployed service, with
        // `identify_key("did:key:z...")`.
        private_signing_key_data: generate_key(KeyType::P256Private)?,
    })
}

The access token that comes back is DPoP-bound, so PDS calls are made with atproto_client::client::dpop_call and a DPoPAuth holding that flow's DPoP key, the one stored in its OAuthRequest:

use atproto_client::client::{DPoPAuth, dpop_call};
use atproto_client::reqwest::{Client, Method, header::HeaderMap};
use atproto_identity::key::KeyData;

async fn list_posts(
    http_client: &Client,
    dpop_key: KeyData,
    access_token: String,
    did: &str,
) -> Result<(), Box<dyn std::error::Error>> {
    let dpop_auth = DPoPAuth {
        dpop_private_key_data: dpop_key,
        oauth_access_token: access_token,
    };

    let url = format!(
        "https://pds.example.com/xrpc/com.atproto.repo.listRecords\
         ?repo={did}&collection=app.bsky.feed.post"
    );
    let response = dpop_call(
        http_client,
        &dpop_auth,
        Method::GET,
        &url,
        None,
        &HeaderMap::new(),
    )
    .await?;

    if let Some(body) = response.body {
        println!("{}", serde_json::to_string_pretty(&body)?);
    }
    Ok(())
}

Refreshing an access token is atproto_oauth::workflow::oauth_refresh, behind atproto_oauth::refresh::RefreshCoordinator — a refresh token rotates on use, so two concurrent refreshes of one session will invalidate it unless they are serialized. Key the coordinator by session, not by DID, since one account signed in on two devices holds two grants. The future it runs has to read the refresh token from storage and save the new tokens before resolving, and fails with the caller's own error type, so a store failure comes back as itself; RefreshCoordinator::refresh's documentation shows how.

Command Line Tools #

The workspace ships command-line tools and server binaries across the crates, ready-to-use utilities for AT Protocol development and testing. Every binary below is behind the clap feature except atpmcp, which is a binary crate; some need hickory-dns for DNS resolution, and the two attestation tools need atproto-attestation's tokio feature for their HTTP client. One line builds them all:

# Build every binary in the workspace
cargo build --bins -F clap,hickory-dns,atproto-attestation/tokio

cargo build --features clap --bins is not that line: it silently skips the two attestation binaries, whose required-features it does not satisfy, and says nothing about having done so. Nor is a bare tokio in the list: atproto-attestation is the only crate with a tokio feature, and Cargo refuses a bare tokio on atproto-lexicon, whose optional tokio dependency is not a feature.

# DASL operations (atproto-dasl crate)
cargo run --package atproto-dasl --features clap --bin atpcid -- '{"text":"hello"}'
cat record.json | cargo run --package atproto-dasl --features clap --bin atpcid -- --record --
echo '{"text":"hello"}' | cargo run --package atproto-dasl --features clap --bin atproto-dasl -- encode

# Identity operations (atproto-identity crate)
cargo run --features clap,hickory-dns --bin atpdid -- resolve alice.bsky.social
cargo run --features clap,hickory-dns --bin atpdid -- key generate p256
cargo run --features clap,hickory-dns --bin atpdid -- key sign signing.key data.json
cargo run --features clap,hickory-dns --bin atpdid -- key verify did:key:... data.json signature
cargo run --features clap,hickory-dns --bin atpdid -- plc verify did:plc:...
cargo run --features clap,hickory-dns --bin atpdid -- plc forks did:plc:...

# Attestation operations (atproto-attestation crate)
# `inline` takes four positionals, and the repository DID is one of them: the
# signature is bound to the repository the record will live in.
cargo run --package atproto-attestation --features clap,tokio --bin atproto-attestation-sign -- inline record.json did:plc:repo123 did:key:... metadata.json
cargo run --package atproto-attestation --features clap,tokio --bin atproto-attestation-verify -- signed_record.json did:plc:repo123

# Record operations (atproto-record crate)
cargo run --package atproto-record --features clap --bin atptid
cargo run --package atproto-record --features clap --bin atptid -- -n 5

# Repository operations (atproto-repo crate)
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

# Lexicon operations (atproto-lexicon crate)
cargo run --features clap,hickory-dns --bin atproto-lexicon-resolve -- app.bsky.feed.post

# XRPC calls (atpxrpc crate)
cargo run --bin atpxrpc -- login alice.bsky.social
cargo run --bin atpxrpc -- com.atproto.repo.listRecords repo=alice.bsky.social collection=app.bsky.feed.post
ATPROTO_ACCESS_TOKEN=... ATPROTO_DPOP_KEY=did:key:... cargo run --bin atpxrpc -- dpop alice.bsky.social com.atproto.repo.listRecords repo=alice.bsky.social collection=app.bsky.feed.post
cargo run --package atpxrpc --bin atpxrpc -- --help

# Service auth tokens (atproto-oauth crate)
# Inter-service auth JWTs, `lxm`-scoped and signed by the issuer's key. This is
# service auth, not the OAuth client flow.
cargo run --package atproto-oauth --features clap --bin atproto-oauth-service-token -- --help

# Personal Data Server (atproto-pds crate)
cargo run --package atproto-pds --features clap --bin pds -- --help
cargo run --package atproto-pds --features clap --bin atproto-pds-admin -- --help

# Entryway (atproto-entryway crate): the account service in front of PDS instances
cargo run --package atproto-entryway --features clap --bin entryway -- --help

# Event streaming (atproto-jetstream crate)
cargo run --features clap --bin atproto-jetstream-consumer -- jetstream.us-east.bsky.network --collections app.bsky.feed.post --kinds commit

# TAP service consumer (atproto-tap crate)
cargo run --package atproto-tap --features clap --bin atproto-tap-client -- --help

# Rich text utilities (atproto-extras crate)
# A mention needs a DID, so --resolve-mentions (network) is what emits one;
# without it the mention is reported on stderr and left out of the array.
cargo run --package atproto-extras --features clap --bin atproto-extras-parse-facets -- --resolve-mentions "Hello @alice.bsky.social"

# MCP server (atpmcp crate)
cargo run --package atpmcp --bin atpmcp -- --help

Development #

The DASL conformance fixtures are a git submodule, so check it out before running the tests, or clone with git clone --recursive. Without it, 13 atproto-dasl tests fail and say which command to run.

git submodule update --init --recursive

CI runs these checks, among others (.tangled/workflows/ci.yml has the full list):

cargo fmt --all -- --check
cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace

# The attestation verifier's command-line tests need two features
cargo test -p atproto-attestation --features clap,tokio

Every Rust example on this page is compiled by cargo test --workspace. Each crate includes its own README as a doctest, and this one is included by crates/readme-doctests, a publish = false member that exists only to hold it. That crate's dev-dependencies are the Quick Start block, and its tests/ check the component list, the binary list and the build line above against cargo metadata --no-deps. An example that needs a crate the Quick Start does not name fails to build, which is what a reader following this page would hit.

Other useful commands:

# Build all crates
cargo build

# Generate documentation
cargo doc --workspace --open

License #

This project is licensed under the MIT License - see the LICENSE file for details.

Architecture #

These components are designed to work together as building blocks for AT Protocol applications:

  • Data Foundation Layer: atproto-dasl provides CID computation, deterministic DAG-CBOR encoding, CAR v1 archives, and pluggable block storage
  • Identity Layer: atproto-identity provides the foundation for DID resolution and cryptographic operations
  • Repository Layer: atproto-repo provides Merkle Search Tree operations, commit structures, and repository handling
  • Data Layer: atproto-record provides the record wire types and the community lexicon types, and atproto-attestation cryptographic attestations; a record's CID comes from atproto-dasl (compute_cid_from_json)
  • Schema Layer: atproto-lexicon validates records and XRPC calls against lexicon schemas, and resolves those schemas by NSID
  • Authentication Layer: atproto-oauth handles OAuth 2.0 flows with AT Protocol security extensions, and atproto-xrpcs the service-auth tokens services present to each other
  • Application Layer: atproto-client and atpxrpc enable client applications and service development
  • Server Layer: atproto-pds is a Personal Data Server built from all of the above, and atproto-space and atproto-space-client are its permissioned-data half
  • Event Layer: atproto-firehose, atproto-jetstream and atproto-tap provide real-time event processing capabilities
  • Utilities Layer: atproto-extras provides rich text facet parsing for AT Protocol applications

Use Cases #

This workspace enables development of:

  • Personal Data Servers - atproto-pds is one, and is also a worked example of assembling the rest
  • OAuth Clients and Authorization Servers - Confidential clients through atproto-oauth's workflow, and the DPoP, PKCE and JWT pieces an authorization server validates with
  • AT Protocol Clients - Applications that authenticate and interact with AT Protocol services
  • Inter-service APIs - XRPC services that authenticate their callers with policy-checked service-auth tokens
  • Event Processing Systems - Real-time processors for AT Protocol repository events via the firehose, Jetstream or Tap
  • Repository Tools - CAR file inspection, MST analysis, and repository verification utilities
  • Development Tools - CLI utilities for testing, debugging, and managing AT Protocol identities

Contributing #

Contributions are welcome! This project follows standard Rust development practices:

  1. Fork this repository
  2. Create a feature branch
  3. Run tests: git submodule update --init --recursive once, then cargo test --workspace (see Development)
  4. Run linting: cargo fmt --all -- --check && cargo clippy --workspace --all-targets -- -D warnings
  5. Submit a pull request

Acknowledgments #

Parts of this project were extracted from the smokesignal.events project, an open-source AT Protocol event and RSVP management application, now a read-only archive. This extraction enables broader community use and contribution to AT Protocol tooling in Rust.