Skip to main content

README.md

# AshAtproto

A full-stack AT Protocol SDK for the [Ash Framework](https://ash-hq.org/), built on top of [atex](https://tangled.org/comet.sh/atex). It allows you to authenticate users via ATProto OAuth, and interact with PDS (Personal Data Server) repositories as if they were standard Ash resources.

## Features

- AshAuthentication Strategy: Enables the ATProto OAuth flow.
- XRPC OAuth Client: An HTTP client that handles session lifecycle, DPoP signatures, and automatic token refreshing.
- Decentralized Data Layer: Treat ATProto repositories as an Ash data layer. Supports creation, deletion, queries and cursor pagination.
- Relationship Helpers: Resolve `strongRef` links and Constellation backlinks across PDS boundaries.
- Igniter Code Generation Tasks: Automatically install auth boilerplate and scaffold Ash resources directly from Lexicon JSON files.

## Installation

Add `ash_atproto` to your list of dependencies in `mix.exs`:

```elixir
def deps do
  [
    {:ash_atproto, "~> 1.0.0"}
  ]
end
```

## Authentication Setup

Rather than manually writing all the boilerplate required to store DIDs, handles, and OAuth tokens, `AshAtproto` provides an Igniter task to automatically configure your User resource.

Run the installer against your user resource:

```bash
mix ash_atproto.install MyApp.Accounts.User
```

This will safely inject the required attributes (`:did`, `:handle`, `:oauth_tokens`) and lifecycle actions (`:register_with_atproto`, `:refresh`) into your resource, and configure the `AshAuthentication` ATProto strategy block.

### Example Configuration

Once installed, your `AshAuthentication` block will look something like this. (Using [AshAuthentication.Secret](https://hexdocs.pm/ash_authentication/AshAuthentication.Secret.html) is recommended for production values).

```elixir
  authentication do
    strategies do
      atproto do
        registration_enabled? true
        base_url MyApp.Secrets

        private_key MyApp.Secrets
        key_id MyApp.Secrets
        
        # define the scopes your app needs
        scopes ["repo:app.bsky.feed.post?action=create", "repo:app.bsky.feed.post?action=delete"]
      end
    end
  end
```

### Localhost OAuth Note

Due to the way the callback URL is parsed, if you're using OAuth in development, you should set this config in your `dev.exs`:

```elixir
config :atex, Atex.OAuth, is_localhost: true
```

You must also use `127.0.0.1` instead of `localhost` in your URLs, [as recommended by RFC8252](https://datatracker.ietf.org/doc/html/rfc8252#section-8.3).


## The ATProto Data Layer

`AshAtproto` allows you to interact with the decentralized network using standard Ash actions. The Data Layer automatically handles JSON-to-Ash structuring, safe attribute filtering, and XRPC network calls.

### Generating Resources from Lexicons

You can generate a fully-typed Ash Resource directly from an ATProto Lexicon JSON file using Igniter:

```bash
mix ash_atproto.gen.resource path/to/app.bsky.feed.post.json MyApp
```

### Example Resource

A resource uses a composite primary key (`repo_did` and `rkey`) and maps directly to a Lexicon collection.

```elixir
defmodule MyApp.App.Bsky.Feed.Post do
  use Ash.Resource, data_layer: AshAtproto.DataLayer

  atproto_repo do
    record_type "app.bsky.feed.post"
  end

  attributes do
    # every atproto record needs these keys
    attribute :repo_did, :string, primary_key?: true, allow_nil?: false
    attribute :rkey, :string, primary_key?: true, allow_nil?: false

    attribute :cid, :string

    # lexicon specific attributes
    attribute :text, :string, allow_nil?: false
    attribute :createdAt, :utc_datetime, allow_nil?: false
  end

  actions do
    defaults [:create, :destroy]

  read :read do
      primary? true
      argument :repo_did, :string, allow_nil?: true

      pagination do
        required? true
        keyset? true
      end

      prepare fn query, _ ->
        repo_did = Ash.Query.get_argument(query, :repo_did)

        repo_did =
          repo_did ||
            (query.context[:private][:actor] && query.context[:private][:actor].did)

        if repo_did do
          # sets the argument in the context, so data layer can access it
          # the data layer also supports the `:uri` param instead
          Ash.Query.set_context(query, %{repo_did: repo_did})
        else
          query
        end
      end
    end
  end
end
```

### Usage

- Reading Public Data (No Auth Required)
Because ATProto data is public by default, you can query any user's repository without an OAuth token:

```elixir
Ash.read!(MyApp.App.Bsky.Feed.Post, args: %{repo_did: "did:plc:user"})
```

- Writing Data (Auth Required)
Writes map to `com.atproto.repo.createRecord` and `deleteRecord`. You must pass the authenticated User as the `actor`. The Data Layer will automatically extract their OAuth tokens, generate DPoP signatures, and execute the XRPC request.

```elixir
current_user = socket.assigns.current_user

Ash.create!(MyApp.App.Bsky.Feed.Post, %{text: "Hello from Elixir!"}, actor: current_user)
```

---

## XRPC OAuth Client

If you need to make arbitrary XRPC requests outside of the Data Layer, you can instantiate the `OAuthClient` directly from an authenticated Ash User resource. The access token is refreshed automatically as required.

```elixir
user_resource = Ash.read_one!(MyApp.Accounts.User, authorize?: false)

{:ok, client} = AshAtproto.XRPC.OAuthClient.new(user_resource)

{:ok, response, client} =
  Atex.XRPC.get(client, "com.atproto.repo.listRecords",
    params: [repo: user_resource.handle, collection: "app.bsky.graph.follow"]
  )
```

### Client Note

The client's state changes when a token is refreshed. Ensure you capture and reuse the updated `client` returned in the tuple.

For more details, including lexicon code generation and typed parameters, check the [Atex.XRPC](https://hexdocs.pm/atex/Atex.XRPC.html) module.

Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc) and published on [HexDocs](https://hexdocs.pm). Once published, the docs can be found at <https://hexdocs.pm/ash_atproto>.