Packages

A small, transport-agnostic atproto client for Gleam: XRPC, identity, OAuth discovery, blobs, and repo CRUD.

Current section

Files

Jump to
Raw

README.md

# atproto_client

> **Alpha, pre-release.** Pre-1.0, evolving alongside
> [at-record](https://tangled.org/@mokkenstorm.dev/at-record); expect breaking
> changes between 0.x releases.

A small, transport-agnostic [atproto](https://atproto.com/) client for Gleam.
No app or lexicon knowledge: XRPC, identity resolution, OAuth discovery,
blobs, and generic repo CRUD. Dual-target (erlang + javascript); browser-only
consumers want the sibling [`atproto_browser`](https://hex.pm/packages/atproto_browser) instead,
which owns the `atproto/browser/*` namespace within the shared `atproto/`
prefix.

## Installation

```sh
gleam add atproto_client
```

## Usage

The client carries a `send` function instead of a hardcoded HTTP backend, so
you choose the transport per target. On Erlang, `gleam_httpc`'s `send_bits`
maps onto the client's `TransportError` channel:

```gleam
import atproto/xrpc
import atproto_core/xrpc as core_xrpc
import gleam/httpc
import gleam/result
import gleam/string

fn client() -> xrpc.Client {
  xrpc.Client(send: fn(req) {
    httpc.send_bits(req)
    |> result.map_error(fn(error) {
      case error {
        httpc.ResponseTimeout -> core_xrpc.Timeout
        httpc.FailedToConnect(_, _) ->
          core_xrpc.ConnectionFailed(string.inspect(error))
        _ -> core_xrpc.Other(string.inspect(error))
      }
    })
  })
}
```

Resolve an identity, then read its records:

```gleam
import atproto/identity
import atproto/repo
import gleam/dynamic/decode

let assert Ok(pds) =
  identity.resolve_pds(client(), identity.default_resolver, "someone.bsky.social")

let assert Ok(rows) =
  repo.list_records(
    client(),
    pds,
    access_token,
    did,
    "app.bsky.feed.post",
    decode.dynamic,
  )
```

For the full walkthrough, including both authentication routes, see the
[getting started guide](https://hexdocs.pm/atproto_client/getting-started.html).

## Architecture

### Core

| Module                  | What it does                                                                           |
| ----------------------- | -------------------------------------------------------------------------------------- |
| `atproto/xrpc`          | `Client`, `TransportError`, `XrpcError`, `get`/`post_json`, `parse`                    |
| `atproto/identity`      | `resolve_pds` (handle/DID -> PDS) via Slingshot `resolveMiniDoc`                       |
| `atproto/auth`          | `create_session`/`refresh_session` (`com.atproto.server.createSession`)                |
| `atproto/repo`          | `list_records`/`create_record`/`get_record`/`put_record`/`delete_record`/`upload_blob` |
| `atproto/blob`          | `Blob` type, its codec, and `public_url` for refs returned by `repo.upload_blob`       |
| `atproto/uri`           | at-uri helpers (`rkey`)                                                                |
| `atproto/constellation` | `get_backlinks` against the microcosm backlink index                                   |

### OAuth

The full atproto OAuth client flow, public or confidential, one module per
step:

| Module                   | What it does                                                                            |
| ------------------------ | --------------------------------------------------------------------------------------- |
| `atproto/oauth/metadata` | Discover and verify the authorization server of a PDS                                   |
| `atproto/oauth/flow`     | `start` (resolve, discover, PAR + PKCE + DPoP) and `callback` (`state`/`iss`, exchange) |
| `atproto/oauth/tokens`   | `refresh`/`revoke` of a `VerifiedSession`                                               |
| `atproto/oauth/resource` | Wraps a `Client` so requests to the session's PDS carry the DPoP-bound token            |
| `atproto/oauth/runner`   | Interprets the sans-io effect kernel against a synchronous `Client`                     |

```gleam
import atproto/oauth/flow
import atproto/oauth/resource
import atproto_core/oauth/transport
import gleam/uri

// login: keep `pending` server-side under flow.state(pending)
let assert Ok(#(redirect_url, pending)) =
  flow.start(
    client(),
    resolver: "https://slingshot.microcosm.blue",
    identifier: "alice.bsky.social",
    client_id: "https://app.example/client-metadata.json",
    redirect_uri: "https://app.example/callback",
    scope: "atproto transition:generic",
    client_auth: transport.PublicClient,
  )

// callback: take (remove) the flow by `state`, hand over the whole query
let assert Ok(query) = uri.parse_query(callback_query_string)
let assert Ok(flow.Authenticated(session, dpop_key)) =
  flow.callback(client(), flow: pending, query:, client_auth: transport.PublicClient)

let authed = resource.client(client(), session:, dpop_key:)
```

`callback` only returns a session once the trust chain checks out: `state`
and `iss` match, the token is DPoP-bound with the `atproto` scope, and its
`sub` is the DID the flow started for, resolved natively to the same
authorization server. Confidential clients pass
`transport.PrivateKeyJwt(sign)`, where `sign(issuer)` returns a freshly
signed client assertion for every attempt.

Every remote URL is parsed by `atproto_core/remote` (https only, no IP
literals or local names), but your `Client` is still part of the boundary:
do not follow redirects (`gleam_httpc`'s default), keep a timeout, and on a
server refuse connections to private, loopback, and link-local addresses.

The modules above are sync wrappers over the sans-io effect kernel in
[`atproto_core`](https://hex.pm/packages/atproto_core). Its
`TransportError`/`XrpcError` types are aliased through `atproto/xrpc`;
constructing the variants needs `atproto_core/xrpc`.

## Development

```sh
gleam test
```