# Technical Documentation: bluesky-social/atproto

> ℹ️ **Provenance:** Multi-Tier Ingestion · [CodeWiki Reference](https://codewiki.google/github.com/bluesky-social/atproto)

### Overview (`README.md`)

# AT Protocol Reference Implementation (TypeScript)

Welcome friends!

This repository contains Bluesky's reference implementation of AT Protocol, and of the `app.bsky` microblogging application service backend.

## About AT Protocol

The Authenticated Transfer Protocol ("ATP" or "atproto") is a decentralized social media protocol, developed by [Bluesky Social PBC](https://bsky.social). Third-party software can be as seamless as first-party: custom feeds, federated services, and alternative clients are all built against the same APIs, so developers are never locked out of the ecosystems they help build. Learn more at:

- [Overview and Guides](https://atproto.com/guides/overview) 👈 Best starting point
- [Github Discussions](https://github.com/bluesky-social/atproto/discussions) 👈 Great place to ask questions
- [Protocol Specifications](https://atproto.com/specs/atp)

The Bluesky Social application encompasses a set of schemas and APIs built in the overall AT Protocol framework. The namespace for these "Lexicons" is `app.bsky.*`.

## What is in here?

**Lexicons:** for both the `com.atproto.*` and `app.bsky.*` are canonically versioned in this repo, for now, under `./lexicons/`. These are JSON files in the [Lexicon schema definition language](https://atproto.com/specs/lexicon), similar to JSON Schema or OpenAPI.

**TypeScript Packages:** everything under [`./packages/`](./packages/) is published to npm under the `@atproto/*` scope (except for the shared utilities in [`./packages/internal/`](./packages/internal/), which use the `@atproto-labs/*` scope). Each package has its own README; [`packages/README.md`](./packages/README.md) lists them all. The short version, if you're new here:

- **Building an app or bot?** Start with [`@atproto/lex`](./packages/lex/lex/README.md) — the type-safe SDK that generates TypeScript from Lexicon schemas and gives you an HTTP (XRPC) client. The rest of [`./packages/lex/`](./packages/lex/) contains its building blocks (data model, JSON/CBOR encoding, schema validation, server routing), which you rarely need to install directly. The older [`@atproto/api`](./packages/api/README.md) client still exists and is still used in parts of this repo, but new code should prefer `@atproto/lex`.
- **Adding "Sign in with Bluesky"?** The OAuth client and server implementations live in [`./packages/oauth/`](./packages/oauth/) — pick the client variant matching your runtime ([browser](./packages/oauth/oauth-client-browser/README.md), [node](./packages/oauth/oauth-client-node/README.md), [expo](./packages/oauth/oauth-client-expo/README.md)).
- **Implementing atproto itself?** The protocol primitives are split into focused packages: identifier parsing ([`syntax`](./packages/syntax/README.md)), DID/handle resolution ([`identity`](./packages/identity/README.md), [`did`](./packages/did/)), signing ([`crypto`](./packages/crypto/README.md)), repository & Merkle Search Tree ([`repo`](./packages/repo/README.md)), and firehose consumption ([`sync`](./packages/sync/)).
- **Everything else** is either a service implementation (see below), shared internal glue ([`common`](./packages/common/README.md), [`common-web`](./packages/common-web/README.md), [`./packages/internal/`](./packages/internal/)), or dev tooling ([`dev-env`](./packages/dev-env/), [`dev-infra`](./packages/dev-infra/)).

**TypeScript Services:** each has its implementation in `packages/<name>` and a thin runtime wrapper in `services/<name>`.

- `pds`: "Personal Data Server", hosting repo content for atproto accounts. See [bluesky-social/pds](https://github.com/bluesky-social/pds) for directions on self-hosting.
- `bsky`: AppView implementation of the `app.bsky.*` API endpoints. Running on main network at `api.bsky.app`.
- `bsync`: Internal synchronization service used by the AppView for cross-service state such as mutes and notifications.
- `ozone`: Moderation service implementing the `tools.ozone.*` API.

## What is not in here?

The source code for the Bluesky Social client app (for web and mobile) can be found at [bluesky-social/social-app](https://github.com/bluesky-social/social-app).

Go programming language source code is in [bluesky-social/indigo](https://github.com/bluesky-social/indigo), including the BGS implementation.

## Contributions

While we do accept contributions, we prioritize high quality issues and pull requests. See [CONTRIBUTING.md](./CONTRIBUTING.md) for the developer quickstart, the rules and guidelines to follow before filing an issue or submitting a PR, and our policy on LLM-assisted contributions. Code conventions are documented in [STYLE_GUIDE.md](./STYLE_GUIDE.md).

## Security disclosures

If you discover any security issues, please send an email to security@bsky.app. The email is automatically CCed to the entire team, and we'll respond promptly. See [SECURITY.md](https://github.com/bluesky-social/atproto/blob/main/SECURITY.md) for more info.

## License

This project is dual-licensed under MIT and Apache 2.0 terms:

- MIT license ([LICENSE-MIT.txt](https://github.com/bluesky-social/atproto/blob/main/LICENSE-MIT.txt) or http://opensource.org/licenses/MIT)
- Apache License, Version 2.0, ([LICENSE-APACHE.txt](https://github.com/bluesky-social/atproto/blob/main/LICENSE-APACHE.txt) or http://www.apache.org/licenses/LICENSE-2.0)

Downstream projects and end users may chose either license individually, or both together, at their discretion. The motivation for this dual-licensing is the additional software patent assurance provided by Apache 2.0.

Bluesky Social PBC has committed to a software patent non-aggression pledge. For details see [the original announcement](https://bsky.social/about/blog/10-01-2025-patent-pledge).

---

### Overview (`.changeset/README.md`)

# Changesets

Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
with multi-package repos, or single-package repos to help you version and publish your code. You can
find the full documentation for it [in our repository](https://github.com/changesets/changesets)

We have a quick list of common questions to get you started engaging with this project in
[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md)

---

### Overview (`interop-test-files/README.md`)

atproto Interop Test Files
==========================

This directory contains reusable files for testing interoperability and specification compliance for atproto (AT Protocol).

These files are a copy of <https://github.com/bluesky-social/atproto-interop-tests/>, which is their canonical source and is shared across atproto implementations. Any change made here must also be contributed upstream to that repository.

The protocol itself is documented at <https://atproto.com/specs/atp>. If there are conflicts or ambiguity between these test files and the specs, the specs are the authority, and these test files should usually be corrected.

These files are intended to be simple (JSON, text files, etc) and mostly self-documenting.

---

### Overview (`lexicons-overrides/README.md`)

# Lexicons Overrides Directory

Any lexicon document placed in this directory will override the corresponding lexicon during resolution (depending on the "resolvers" config in `lexicons.json`):

```json
{
  "version": 2,
  "resolvers": [
    // Canonical lexicons from this repository, always included first
    {
      "type": "directory",
      "path": "../../lexicons",
      "include": ["com.atproto.*", "tools.ozone.*"]
    },

    // Overrides from this directory, always specified after the canonical lexicons
    {
      "type": "directory",
      "path": "../../lexicons-overrides"
    }

    // Other resolvers come here
  ]
}
```

Because this resolver is listed after the canonical one (which resolves `com.atproto.*` / `tools.ozone.*` first), overrides apply to lexicons that would otherwise be resolved from the network or the `bsky` GitHub repository — not to the canonical namespaces. Valid use cases include:

- Providing prototypes or experimental versions of lexicons before they are finalized.
- Overriding a network- or GitHub-resolved lexicon for testing or development purposes.
- Providing a copy of a lexicon document that can no longer be resolved from its upstream source.
- Relaxing a published third-party lexicon that existing network records violate, so read paths accept them (see [Compatibility overrides](#compatibility-overrides)).

> [!NOTE]
>
> The file path of the override should match the lexicon it is intended to override. For example, to override `com.atproto.example`, the file should be named `com/atproto/example.json` and must contain a valid JSON lexicon document with an `id` field matching the lexicon ID (e.g., `"id": "com.atproto.example"`).

Create JSON Lexicon schemas in this directory to provide custom implementations or modifications for specific lexicons instead of attempting to resolve them from the canonical source (AT Protocol lexicon resolution).

> [!NOTE]
>
> Please keep this directory tidy. Do remove any override file that is no longer needed (then run `pnpm run lex:update`).

## Compatibility overrides

A compatibility override is a copy of a published third-party lexicon with the smallest relaxation that lets read paths accept records already on the network. Keep every other constraint identical to the upstream document.

Packages that validate writes (the PDS) must keep validating against the publisher's schema, so they must exclude every compatibility override from their overrides resolver (see the `exclude` list in [packages/pds/lexicons.json](../packages/pds/lexicons.json)). Read-only consumers (the AppView and Ozone) resolve them like any other override.

- `social.grain.photo`
  - Upstream: `at://did:plc:e7rftrdyz5e2rw4y6ocszew2/com.atproto.lexicon.schema/social.grain.photo` (CID `bafyreiec24azdo2caoijpefea3x6qfewjvkfu3zcjj3u3lwf3nx3q7nunu`).
  - Relaxation: `alt` is no longer required.
  - Evidence: published Grain photo records omit `alt` (observed 2026-10-17), so the AppView dropped them from gallery views.
  - Remove when: Grain backfills `alt` on existing records or publishes the relaxation.

---

### Overview (`lexicons/README.md`)

# Canonical Lexicons Directory

This folder contains the canonical lexicons definitions of the `com.atproto` and `tools.ozone` namespaces.

Any change made to these lexicons will be [published](https://atproto.com/specs/lexicon#lexicon-publication-and-resolution) on the AT Protocol once landed on the `main` branch.

The canonical source for the `app.bsky` and `chat.bsky` lexicons is the [bluesky-social/bsky](https://github.com/bluesky-social/bsky) GitHub repository.

Other lexicons namespaces used in this repository are either resolved from the AT Protocol network, or hard wired in the [lexicons-overrides](../lexicons-overrides) directory.

Any lexicon document fetched from the network or the `bsky` GitHub repository is committed (hard wired) into the consuming package for offline builds and CI. Only the canonical `com.atproto` / `tools.ozone` schemas, which each package symlinks back to this directory, are left out of version control there.

---

### Overview (`packages/README.md`)

# Packages

Every package in this directory is published to npm. Packages under [`./internal/`](./internal/) are published under the `@atproto-labs/*` scope; all others use `@atproto/*`. Thin runtime wrappers for the services live in [`../services/`](../services/).

## Services

- [`pds`](./pds): The Personal Data Server (PDS) — account management, repo storage, blob storage, and OAuth provider. atproto's main server-side implementation.
- [`bsky`](./bsky): The `app.bsky.*` AppView — read-side service for timelines, profiles, feed generators, and view hydration.
- [`bsync`](./bsync): Cross-AppView synchronization service (mutes, notifications) over Connect-RPC.
- [`ozone`](./ozone): Moderation service implementing the `tools.ozone.*` API.

## Lexicon SDK

The modern, type-safe Lexicon toolchain. Most consumers only need [`@atproto/lex`](./lex/lex), which re-exports the core pieces.

- [`lex`](./lex/lex): The umbrella SDK — `lex install` / `lex build` CLI, typed XRPC client, and re-exports of the packages below.
- [`lex-schema`](./lex/lex-schema): The runtime schema system that generated code is built on.
- [`lex-builder`](./lex/lex-builder): Generates TypeScript schemas from Lexicon documents.
- [`lex-document`](./lex/lex-document): Validation of Lexicon documents themselves.
- [`lex-installer`](./lex/lex-installer): Resolves and installs Lexicon documents into a project.
- [`lex-resolver`](./lex/lex-resolver): Resolves Lexicon documents from the network.
- [`lex-data`](./lex/lex-data): Core data-model types and utilities.
- [`lex-json`](./lex/lex-json): JSON encoding/decoding of Lexicon data.
- [`lex-cbor`](./lex/lex-cbor): CBOR (DRISL) encoding/decoding of Lexicon data.
- [`lex-client`](./lex/lex-client): HTTP (XRPC) client for Lexicon-defined APIs.
- [`lex-server`](./lex/lex-server): Request router for Lexicon-defined APIs.
- [`lex-password-session`](./lex/lex-password-session): Password-based client authentication, for scripts and bots.
- [`xrpc-server`](./xrpc-server): Express-based XRPC server. `server.add()` registers routes from generated lexicon schemas; used by all four services in this repo.

## OAuth

- [`oauth-provider`](./oauth/oauth-provider): OAuth 2.1 provider, as used by the PDS.
- [`oauth-provider-api`](./oauth/oauth-provider-api): Shared types between the OAuth provider and its UI.
- [`oauth-provider-ui`](./oauth/oauth-provider-ui): Sign-in and sign-up interface for the OAuth provider.
- [`oauth-client`](./oauth/oauth-client): Runtime-agnostic OAuth client base.
- [`oauth-client-browser`](./oauth/oauth-client-browser): OAuth client for browsers (WebCrypto + IndexedDB).
- [`oauth-client-node`](./oauth/oauth-client-node): OAuth client for Node.js.
- [`oauth-client-expo`](./oauth/oauth-client-expo): OAuth client for Expo applications.
- [`oauth-client-browser-example`](./oauth/oauth-client-browser-example): Example single-page app using atproto OAuth.
- [`oauth-types`](./oauth/oauth-types): OAuth typing and validation.
- [`oauth-scopes`](./oauth/oauth-scopes): Manipulation and validation of atproto OAuth scopes.
- [`jwk`](./oauth/jwk): JSON Web Key abstractions, extended by the implementations below.
- [`jwk-jose`](./oauth/jwk-jose): `jose`-backed implementation of `@atproto/jwk`.
- [`jwk-webcrypto`](./oauth/jwk-webcrypto): WebCrypto-backed implementation of `@atproto/jwk`.

## Protocol libraries

- [`syntax`](./syntax): Identifier and string-format validation: DID, handle, NSID, AT URI, TID, record key, datetime.
- [`did`](./did): DID parsing, resolution, and verification.
- [`identity`](./identity): Decentralized identity resolution combining DIDs and handles.
- [`crypto`](./crypto): Cryptographic keys and signing.
- [`repo`](./repo): The atproto repository implementation (a Merkle Search Tree) and CAR handling.
- [`sync`](./sync): Firehose consumption and repo synchronization.
- [`tap`](./tap): Client for the atproto tap (event stream) interface.
- [`ws-client`](./ws-client): Long-lived WebSocket client connections.
- [`lexicon-resolver`](./lexicon-resolver): Network resolution of Lexicon documents.

## Earlier client and schema stack

Being replaced by the Lexicon SDK above. Still used in parts of this repo; prefer `@atproto/lex` for new code.

- [`api`](./api): Client library for atproto and Bluesky.
- [`lexicon`](./lexicon): The original Lexicon schema language and validation library.
- [`xrpc`](./xrpc): XRPC client implementation.
- [`lex-cli`](./lex-cli): Codegen tool for the `@atproto/api` / `@atproto/lexicon` stack.

## Shared internals

Utility packages published under the `@atproto-labs/*` scope, all located in [`./internal/`](./internal/).

- [`fetch`](./internal/fetch): Isomorphic wrappers around the `fetch` API.
- [`fetch-node`](./internal/fetch-node): SSRF protection for `fetch()` in Node.js.
- [`pipe`](./internal/pipe): Composition of multiple functions into one.
- [`did-resolver`](./internal/did-resolver): DID resolution and verification.
- [`handle-resolver`](./internal/handle-resolver): Isomorphic handle-to-DID resolution.
- [`handle-resolver-node`](./internal/handle-resolver-node): Node-specific handle-to-DID resolution.
- [`identity-resolver`](./internal/identity-resolver): Full atproto identity resolution.
- [`simple-store`](./internal/simple-store): Minimal key-value store interfaces and utilities.
- [`simple-store-memory`](./internal/simple-store-memory): In-memory `SimpleStore`.
- [`simple-store-redis`](./internal/simple-store-redis): Redis-backed `SimpleStore`.
- [`xrpc-utils`](./internal/xrpc-utils): XRPC server utilities for Node.js.
- [`rolldown-plugin-bundle-manifest`](./internal/rolldown-plugin-bundle-manifest): Generates a manifest of bundled files from a Rolldown build.

A few further shared libraries live at the top level under the `@atproto/*` scope:

- [`common`](./common): Code shared between atproto packages (Node-friendly).
- [`common-web`](./common-web): The web-platform-friendly subset of `common`.
- [`aws`](./aws): AWS helpers (S3 blob storage, etc.) for atproto services.

## Development tooling

- [`dev-env`](./dev-env): Boots a full PDS + AppView + bsync + PLC + Ozone constellation in-process, for tests and the `make run-dev-env` REPL.
- [`dev-infra`](./dev-infra): Docker Compose definitions for the backing services (Postgres, Redis, etc.) used by tests.

---

### Overview (`packages/api/README.md`)

# ATP API

This API is a client for ATProtocol servers. It communicates using HTTP. It includes:

- ✔️ APIs for ATProto and Bluesky.
- ✔️ Validation and complete typescript types.
- ✔️ Session management.
- ✔️ A RichText library.

> [!Note]
>
> We recommend using [lex](https://github.com/bluesky-social/atproto/tree/main/packages/lex/lex), our type-safe SDK with Lexicon code generation, for all new projects.

## Getting started

First install the package:

```sh
yarn add @atproto/api
```

Then in your application:

```typescript
import { Agent, CredentialSession } from '@atproto/api'

const session = new CredentialSession(new URL('https://bsky.social'))
await session.login(account)
const agent = new Agent(session)
```

## Usage

### Session management

You'll need an authenticated session for most API calls. There are two ways to
manage sessions:

1. [App password based session management](#app-password-based-session-management)
2. [OAuth based session management](#oauth-based-session-management)

#### App password based session management

Username / password based authentication can be performed using the `Agent`
class.

> [!CAUTION]
>
> This method is deprecated in favor of OAuth based session management. It is
> recommended to use OAuth based session management (through the
> `@atproto/oauth-client-*` packages).

```typescript
import { Agent, CredentialSession, type AtpAgentLoginOpts } from '@atproto/api'

// Configure connection to the server with authentification
const account: AtpAgentLoginOpts = {
  identifier: 'your.bsky.social',
  password: 'xxxx-xxxx-xxxx-xxxx',
}

async function authenticate(account: AtpAgentLoginOpts): Promise<Agent> {
  const session = new CredentialSession(new URL('https://example.com'))
  await session.login(account)
  const agent = new Agent(session)
  return agent
}

;(async () => {
  console.log('Authenticating...')
  const agent = await authenticate(account)
  console.log(`Authenticated as from: ${agent.sessionManager.did}`)
})()
```

#### OAuth based session management

Depending on the environment used by your application, different OAuth clients
are available:

- [@atproto/oauth-client-browser](https://www.npmjs.com/package/@atproto/oauth-client-browser):
  for the browser.
- [@atproto/oauth-client-node](https://www.npmjs.com/package/@atproto/oauth-client-node): for
  Node.js.
- [@atproto/oauth-client](https://www.npmjs.com/package/@atproto/oauth-client):
  Lower level; compatible with most JS engines.

Every `@atproto/oauth-client-*` implementation has a different way to obtain an
`OAuthSession` instance that can be used to instantiate an `Agent` (from
`@atproto/api`). Here is an example restoring a previously saved session:

```typescript
import { Agent } from '@atproto/api'
import { OAuthClient } from '@atproto/oauth-client'

const oauthClient = new OAuthClient({
  // ...
})

const oauthSession = await oauthClient.restore('did:plc:123')

// Instantiate the api Agent using an OAuthSession
const agent = new Agent(oauthSession)
```

### API calls

The agent includes methods for many common operations, including:

```
/* Detailed source-code truncated for AI context efficiency. */
```

### Validation and types

The package includes a complete types system which includes validation and type-guards. For example, to validate a post record:

```typescript
import { AppBskyFeedPost } from '@atproto/api'

const post = {...}
if (AppBskyFeedPost.isRecord(post)) {
  // typescript now recognizes `post` as a AppBskyFeedPost.Record
  // however -- we still need to validate it
  const res = AppBskyFeedPost.validateRecord(post)
  if (res.success) {
    // a valid record
  } else {
    // something is wrong
    console.log(res.error)
  }
}
```

### Rich text

Some records (ie posts) use the `app.bsky.richtext` lexicon. At the moment richtext is only used for links and mentions, but it will be extended over time to include bold, italic, and so on.

ℹ️ It is **strongly** recommended to use this package's `RichText` library. Javascript encodes strings in utf16 while the protocol (and most other programming environments) use utf8. Converting between the two is challenging, but `RichText` handles that for you.

```typescript
import { RichText } from '@atproto/api'

// creating richtext
const rt = new RichText({
  text: 'Hello @alice.com, check out this link: https://example.com',
})
await rt.detectFacets(agent) // automatically detects mentions and links
const postRecord = {
  $type: 'app.bsky.feed.post',
  text: rt.text,
  facets: rt.facets,
  createdAt: new Date().toISOString(),
}

// rendering as markdown
let markdown = ''
for (const segment of rt.segments()) {
  if (segment.isLink()) {
    markdown += `[${segment.text}](${segment.link?.uri})`
  } else if (segment.isMention()) {
    markdown += `[${segment.text}](https://my-bsky-app.com/user/${segment.mention?.did})`
  } else {
    markdown += segment.text
  }
}

// calculating string lengths
const rt2 = new RichText({ text: 'Hello' })
console.log(rt2.length) // => 5
console.log(rt2.graphemeLength) // => 5
const rt3 = new RichText({ text: '👨‍👩‍👧‍👧' })
console.log(rt3.length) // => 25
console.log(rt3.graphemeLength) // => 1
```

### Moderation

Applying the moderation system is a challenging task, but we've done our best to simplify it for you. The Moderation API helps handle a wide range of tasks, including:

- Moderator labeling
- User muting (including mutelists)
- User blocking
- Mutewords
- Hidden posts

For more information, see the [Moderation Documentation](./docs/moderation.md).

```
/* Detailed source-code truncated for AI context efficiency. */
```

## Advanced

### Advanced API calls

The methods above are convenience wrappers. It covers most but not all available methods.

The AT Protocol identifies methods and records with reverse-DNS names. You can use them on the agent as well:

```typescript
const res1 = await agent.com.atproto.repo.createRecord({
  did: alice.did,
  collection: 'app.bsky.feed.post',
  record: {
    $type: 'app.bsky.feed.post',
    text: 'Hello, world!',
    createdAt: new Date().toISOString(),
  },
})
const res2 = await agent.com.atproto.repo.listRecords({
  repo: alice.did,
  collection: 'app.bsky.feed.post',
})

const res3 = await agent.app.bsky.feed.post.create(
  { repo: alice.did },
  {
    text: 'Hello, world!',
    createdAt: new Date().toISOString(),
  },
)
const res4 = await agent.app.bsky.feed.post.list({ repo: alice.did })
```

### Non-browser configuration

If your environment doesn't have a built-in `fetch` implementation, you'll need
to provide one. This will typically be done through a polyfill.

### Bring your own fetch

If you want to provide your own `fetch` implementation, you can do so by
instantiating the sessionManager with a custom fetch implementation:

```typescript
import { AtpAgent } from '@atproto/api'

const myFetch = (input: RequestInfo | URL, init?: RequestInit) => {
  console.log('requesting', input)
  const response = await globalThis.fetch(input, init)
  console.log('got response', response)
  return response
}

const agent = new AtpAgent({
  service: 'https://example.com',
  fetch: myFetch,
})
```

## License

This project is dual-licensed under MIT and Apache 2.0 terms:

- MIT license ([LICENSE-MIT.txt](https://github.com/bluesky-social/atproto/blob/main/LICENSE-MIT.txt) or http://opensource.org/licenses/MIT)
- Apache License, Version 2.0, ([LICENSE-APACHE.txt](https://github.com/bluesky-social/atproto/blob/main/LICENSE-APACHE.txt) or http://www.apache.org/licenses/LICENSE-2.0)

Downstream projects and end users may chose either license individually, or both together, at their discretion. The motivation for this dual-licensing is the additional software patent assurance provided by Apache 2.0.

---

### Overview (`packages/aws/README.md`)

# AWS KMS

A Keypair-compatible wrapper for AWS KMS.

---

### Overview (`packages/bsky/README.md`)

# @atproto/bsky: Bluesky AppView Service

TypeScript implementation of the `app.bsky` Lexicons backing the https://bsky.app microblogging application.

[](https://www.npmjs.com/package/@atproto/bsky)
[](https://github.com/bluesky-social/atproto/actions/workflows/repo.yaml)

## License

This project is dual-licensed under MIT and Apache 2.0 terms:

- MIT license ([LICENSE-MIT.txt](https://github.com/bluesky-social/atproto/blob/main/LICENSE-MIT.txt) or http://opensource.org/licenses/MIT)
- Apache License, Version 2.0, ([LICENSE-APACHE.txt](https://github.com/bluesky-social/atproto/blob/main/LICENSE-APACHE.txt) or http://www.apache.org/licenses/LICENSE-2.0)

Downstream projects and end users may chose either license individually, or both together, at their discretion. The motivation for this dual-licensing is the additional software patent assurance provided by Apache 2.0.

---

### Overview (`packages/bsync/README.md`)

# @atproto/bsync: Synchronizing Service for the Bluesky AppView

This is an optional service that may be used to synchronize certain state between otherwise independent AppViews.

[](https://www.npmjs.com/package/@atproto/bsync)
[](https://github.com/bluesky-social/atproto/actions/workflows/repo.yaml)

## License

This project is dual-licensed under MIT and Apache 2.0 terms:

- MIT license ([LICENSE-MIT.txt](https://github.com/bluesky-social/atproto/blob/main/LICENSE-MIT.txt) or http://opensource.org/licenses/MIT)
- Apache License, Version 2.0, ([LICENSE-APACHE.txt](https://github.com/bluesky-social/atproto/blob/main/LICENSE-APACHE.txt) or http://www.apache.org/licenses/LICENSE-2.0)

Downstream projects and end users may chose either license individually, or both together, at their discretion. The motivation for this dual-licensing is the additional software patent assurance provided by Apache 2.0.

---

### Overview (`packages/common-web/README.md`)

# @atproto/common-web

Shared TypeScript code for other `@atproto/*` packages, which is web-friendly (runs in-browser).

[](https://www.npmjs.com/package/@atproto/common-web)
[](https://github.com/bluesky-social/atproto/actions/workflows/repo.yaml)

## License

This project is dual-licensed under MIT and Apache 2.0 terms:

- MIT license ([LICENSE-MIT.txt](https://github.com/bluesky-social/atproto/blob/main/LICENSE-MIT.txt) or http://opensource.org/licenses/MIT)
- Apache License, Version 2.0, ([LICENSE-APACHE.txt](https://github.com/bluesky-social/atproto/blob/main/LICENSE-APACHE.txt) or http://www.apache.org/licenses/LICENSE-2.0)

Downstream projects and end users may chose either license individually, or both together, at their discretion. The motivation for this dual-licensing is the additional software patent assurance provided by Apache 2.0.

---

### Overview (`packages/common/README.md`)

# @atproto/common

Shared TypeScript code for other `@atproto/*` packages. This package is oriented towards writing servers, and is not designed to be browser-compatible.

[](https://www.npmjs.com/package/@atproto/common)
[](https://github.com/bluesky-social/atproto/actions/workflows/repo.yaml)

## License

This project is dual-licensed under MIT and Apache 2.0 terms:

- MIT license ([LICENSE-MIT.txt](https://github.com/bluesky-social/atproto/blob/main/LICENSE-MIT.txt) or http://opensource.org/licenses/MIT)
- Apache License, Version 2.0, ([LICENSE-APACHE.txt](https://github.com/bluesky-social/atproto/blob/main/LICENSE-APACHE.txt) or http://www.apache.org/licenses/LICENSE-2.0)

Downstream projects and end users may chose either license individually, or both together, at their discretion. The motivation for this dual-licensing is the additional software patent assurance provided by Apache 2.0.

---

### Overview (`packages/crypto/README.md`)

# @atproto/crypto

TypeScript library providing basic cryptographic helpers as needed in [atproto](https://atproto.com).

[](https://www.npmjs.com/package/@atproto/crypto)
[](https://github.com/bluesky-social/atproto/actions/workflows/repo.yaml)

This package implements the two currently supported cryptographic systems:

- P-256 elliptic curve: aka "NIST P-256", aka secp256r1 (note the r), aka prime256v1
- K-256 elliptic curve: aka "NIST K-256", aka secp256k1 (note the k)

The details of cryptography in atproto are described in [the specification](https://atproto.com/specs/cryptography). This includes string encodings, validity of "low-S" signatures, byte representation "compression", hashing, and more.

## Usage

```typescript
import { verifySignature, Secp256k1Keypair, P256Keypair } from '@atproto/crypto'

// generate a new random K-256 private key
const keypair = await Secp256k1Keypair.create({ exportable: true })

// sign binary data, resulting signature bytes.
// SHA-256 hash of data is what actually gets signed.
// signature output is often base64-encoded.
const data = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8])
const sig = await keypair.sign(data)

// serialize the public key as a did:key string, which includes key type metadata
const pubDidKey = keypair.did()
console.log(pubDidKey)

// output would look something like: 'did:key:zQ3shVRtgqTRHC7Lj4DYScoDgReNpsDp3HBnuKBKt1FSXKQ38'

// verify signature using public key
const ok = verifySignature(pubDidKey, data, sig)
if (!ok) {
  throw new Error('Uh oh, something is fishy')
} else {
  console.log('Success')
}
```

## License

This project is dual-licensed under MIT and Apache 2.0 terms:

- MIT license ([LICENSE-MIT.txt](https://github.com/bluesky-social/atproto/blob/main/LICENSE-MIT.txt) or http://opensource.org/licenses/MIT)
- Apache License, Version 2.0, ([LICENSE-APACHE.txt](https://github.com/bluesky-social/atproto/blob/main/LICENSE-APACHE.txt) or http://www.apache.org/licenses/LICENSE-2.0)

Downstream projects and end users may chose either license individually, or both together, at their discretion. The motivation for this dual-licensing is the additional software patent assurance provided by Apache 2.0.

---

### Overview (`packages/dev-env/README.md`)

# @atproto/dev-env: Local Developer Environment

A command-line application for developers to construct and manage development environments.

[](https://www.npmjs.com/package/@atproto/dev-env)
[](https://github.com/bluesky-social/atproto/actions/workflows/repo.yaml)

## REPL API

The following methods are available in the REPL.

### `status()`

List the currently active servers.

### `startPds(port?: number)`

Create a new PDS instance. Data is stored in memory.

### `stop(port: number)`

Stop the server at the given port.

### `mkuser(handle: string, pdsPort?: number)`

Create a new user.

### `user(handle: string): ServiceClient`

Get the `ServiceClient` for the given user.

## License

This project is dual-licensed under MIT and Apache 2.0 terms:

- MIT license ([LICENSE-MIT.txt](https://github.com/bluesky-social/atproto/blob/main/LICENSE-MIT.txt) or http://opensource.org/licenses/MIT)
- Apache License, Version 2.0, ([LICENSE-APACHE.txt](https://github.com/bluesky-social/atproto/blob/main/LICENSE-APACHE.txt) or http://www.apache.org/licenses/LICENSE-2.0)

Downstream projects and end users may chose either license individually, or both together, at their discretion. The motivation for this dual-licensing is the additional software patent assurance provided by Apache 2.0.

---

### Overview (`packages/dev-infra/README.md`)

# dev-infra

Helpers for working with postgres and redis locally. Previously known as `pg`.

## Usage

### `with-test-db.sh`

This script allows you to run any command with a fresh, ephemeral/single-use postgres database available. When the script starts a Dockerized postgres container starts-up, and when the script completes that container is removed.

The environment variable `DB_POSTGRES_URL` will be set with a connection string that can be used to connect to the database. The [`PG*` environment variables](https://www.postgresql.org/docs/current/libpq-envars.html) that are recognized by libpq (i.e. used by the `psql` client) are also set.

**Example**

```
$ ./with-test-db.sh psql -c 'select 1;'
[+] Running 1/1
 ⠿ Container pg-db_test-1  Healthy                                                           1.8s

 ?column?
----------
        1
(1 row)


[+] Running 1/1
 ⠿ Container pg-db_test-1  Stopped                                                           0.1s
Going to remove pg-db_test-1
[+] Running 1/0
 ⠿ Container pg-db_test-1  Removed
```

### `with-redis-and-test-db.sh`

This script is similar to `with-test-db.sh`, but in addition to an ephemeral/single-use postgres database it also provides a single-use redis instance. When the script starts, Dockerized postgres and redis containers start-up, and when the script completes the containers are removed.

The environment variables `DB_POSTGRES_URL` and `REDIS_HOST` will be set with a connection strings that can be used to connect to postgres and redis respectively.

### `docker-compose.yaml`

The Docker compose file can be used to run containerized versions of postgres either for single use (as is used by `with-test-db.sh`), or for longer-term use. These are setup as separate services named `db_test` and `db` respectively. In both cases the database is available on the host machine's `localhost` and credentials are:

- Username: pg
- Password: password

However, each service uses a different port, documented below, to avoid conflicts.

#### `db_test` service for single use

The single-use `db_test` service does not have any persistent storage. When the container is removed, data in the database disappears with it.

This service runs on port `5433`.

```
$ docker compose up db_test   # start container
$ docker compose stop db_test # stop container
$ docker compose rm db_test   # remove container
```

#### `db` service for persistent use

The `db` service has persistent storage on the host machine managed by Docker under a volume named `pg_atp_db`. When the container is removed, data in the database will remain on the host machine. In order to start fresh, you would need to remove the volume.

This service runs on port `5432`.

```
$ docker compose up db -d    # start container
$ docker compose stop db     # stop container
$ docker compose rm db       # remove container
$ docker volume rm pg_atp_db # remove volume
```

#### `redis_test` service for single use

The single-use `redis_test` service does not have any persistent storage. When the container is removed, the data in redis disappears with it.

This service runs on port `6380`.

#### `redis` service for persistent use

The `redis` service has persistent storage on the host machine managed by Docker under a volume named `atp_redis`. When the container is removed, the data in redis will remain on the host machine. In order to start fresh, you would need to remove the volume.

This service runs on port `6379`.

---

### Overview (`packages/identity/README.md`)

# @atproto/identity

TypeScript library for decentralized identities in [atproto](https://atproto.com) using DIDs and handles

[](https://www.npmjs.com/package/@atproto/identity)
[](https://github.com/bluesky-social/atproto/actions/workflows/repo.yaml)

## Example

Resolving a Handle and verifying against DID document:

```typescript
const didres = new DidResolver({})
const hdlres = new HandleResolver({})

const handle = 'atproto.com'
const did = await hdlres.resolve(handle)

if (did == undefined) {
  throw new Error('expected handle to resolve')
}
console.log(did) // did:plc:ewvi7nxzyoun6zhxrhs64oiz

const doc = await didres.resolve(did)
console.log(doc)

// additional resolutions of same DID will be cached for some time, unless forceRefresh flag is used
const doc2 = await didres.resolve(did, true)

// helper methods use the same cache
const data = await didres.resolveAtprotoData(did)

if (data.handle != handle) {
  throw new Error('invalid handle (did not match DID document)')
}
```

## SSRF protection

HTTP identity resolution — the handle `/.well-known/atproto-did` endpoint,
`did:plc`, and `did:web` — is routed through an SSRF-protected fetch by
default. Private IPs, IP-literal hosts, `http:` URLs, custom ports, and a
handful of forbidden domain names are refused.

To resolve identities against localhost or another private network — a test
suite or a local dev stack — pass your own `fetch`. A supplied `fetch` is
used as-is and is never re-wrapped:

```typescript
const didres = new IdResolver({ plcUrl, fetch: globalThis.fetch })
```

## License

This project is dual-licensed under MIT and Apache 2.0 terms:

- MIT license ([LICENSE-MIT.txt](https://github.com/bluesky-social/atproto/blob/main/LICENSE-MIT.txt) or http://opensource.org/licenses/MIT)
- Apache License, Version 2.0, ([LICENSE-APACHE.txt](https://github.com/bluesky-social/atproto/blob/main/LICENSE-APACHE.txt) or http://www.apache.org/licenses/LICENSE-2.0)

Downstream projects and end users may chose either license individually, or both together, at their discretion. The motivation for this dual-licensing is the additional software patent assurance provided by Apache 2.0.

---

### Overview (`packages/lex-cli/README.md`)

# Lexicon CLI Tool

Command-line tool to generate Lexicon schemas and APIs.

## Usage

```
Usage: lex [options] [command]

Lexicon CLI

Options:
  -V, --version                     output the version number
  -h, --help                        display help for command

Commands:
  new [options] <nsid> [outfile]    Create a new schema json file
  gen-md <schemas...>               Generate markdown documentation
  gen-ts-obj <schemas...>           Generate a TS file that exports an array of schemas
  gen-api <outdir> <schemas...>     Generate a TS client API
  gen-server <outdir> <schemas...>  Generate a TS server API
  help [command]                    display help for command
```

**Example 1:** Generate an api

```
$ lex gen-api ./api/src ./schemas/com/service/*.json ./schemas/com/another/*.json
```

**Example 2:** Generate a server

```
$ lex gen-server ./server/src/xrpc ./schemas/com/service/*.json ./schemas/com/another/*.json
```

## License

This project is dual-licensed under MIT and Apache 2.0 terms:

- MIT license ([LICENSE-MIT.txt](https://github.com/bluesky-social/atproto/blob/main/LICENSE-MIT.txt) or http://opensource.org/licenses/MIT)
- Apache License, Version 2.0, ([LICENSE-APACHE.txt](https://github.com/bluesky-social/atproto/blob/main/LICENSE-APACHE.txt) or http://www.apache.org/licenses/LICENSE-2.0)

Downstream projects and end users may chose either license individually, or both together, at their discretion. The motivation for this dual-licensing is the additional software patent assurance provided by Apache 2.0.

---

### Overview (`packages/lexicon-resolver/README.md`)

# @atproto/lexicon-resolver

ATProto Lexicon resolution

[](https://www.npmjs.com/package/@atproto/lexicon-resolver)
[](https://github.com/bluesky-social/atproto/actions/workflows/repo.yaml)

## Usage

This package may be used to determine the DID authority for a Lexicon based on its NSID, and to resolve a Lexicon from its NSID based on [Lexicon Resolution](https://atproto.com/specs/lexicon#lexicon-publication-and-resolution) from the network. Resolutions always verify the inclusion proof for the Lexicon schema document published to the ATProto network.

```ts
import {
  resolveLexicon,
  resolveLexiconDidAuthority,
} from '@atproto/lexicon-resolver'

// Which DID is the authority over this Lexicon?
const didAuthority = await resolveLexiconDidAuthority('app.bsky.feed.post')
// Resolve the Lexicon document with resolution details
const resolved = await resolveLexicon('app.bsky.feed.post')
/**
 * {
 *   commit: {
 *     did: 'did:plc:4v4y5r3lwsbtmsxhile2ljac',
 *     rev: '3lnlpukgipj2c',
 *     sig: Uint8Array(64),
 *     ...
 *   },
 *   uri: AtUri(at://did:plc:4v4y5r3lwsbtmsxhile2ljac/com.atproto.lexicon.schema/app.bsky.feed.post),
 *   cid: CID(bafyreidgbehqwweghrrddfu6jgj7lyr6fwhzgazhirnszdb5lvr7iynkiy),
 *   nsid: NSID('app.bsky.feed.post'),
 *   lexicon: {
 *     '$type': 'com.atproto.lexicon.schema',
 *     lexicon: 1
 *     id: 'app.bsky.feed.post',
 *     defs: { main: [Object], ... },
 *   }
 * }
 */
```

### With identity caching

Identity data is used in order to fetch and verify record contents. The @atproto/identity package can be used to offer more control over caching and other behaviors of identity lookups.

```ts
import { IdResolver, MemoryCache } from '@atproto/identity'
import { buildLexiconResolver } from '@atproto/lexicon-resolver'

const resolveLexicon = buildLexiconResolver({
  idResolver: new IdResolver({
    didCache: new MemoryCache(),
  }),
})

const resolved = await resolveLexicon('app.bsky.feed.post')
```

### With DID authority override

You may specify a specific DID authority you'd like to use to perform a Lexicon resolution, overriding ATProto's DNS-based authority over Lexicons. This is described in some more detail in [Authority and Control](https://atproto.com/specs/lexicon#authority-and-control).

```ts
import { resolveLexicon } from '@atproto/lexicon-resolver'

const resolved = await resolveLexicon('app.bsky.feed.post', {
  didAuthority: 'did:plc:...',
})
```

## License

This project is dual-licensed under MIT and Apache 2.0 terms:

- MIT license ([LICENSE-MIT.txt](https://github.com/bluesky-social/atproto/blob/main/LICENSE-MIT.txt) or http://opensource.org/licenses/MIT)
- Apache License, Version 2.0, ([LICENSE-APACHE.txt](https://github.com/bluesky-social/atproto/blob/main/LICENSE-APACHE.txt) or http://www.apache.org/licenses/LICENSE-2.0)

Downstream projects and end users may chose either license individually, or both together, at their discretion. The motivation for this dual-licensing is the additional software patent assurance provided by Apache 2.0.

---

### Overview (`packages/lexicon/README.md`)

# @atproto/lexicon: schema validation library

TypeScript implementation of the Lexicon data and API schema description language, which is part of [atproto](https://atproto.com).

[](https://www.npmjs.com/package/@atproto/lexicon)
[](https://github.com/bluesky-social/atproto/actions/workflows/repo.yaml)

## Usage

```typescript
import { Lexicons } from '@atproto/lexicon'

// create your lexicons collection
const lex = new Lexicons()

// add lexicon documents
lex.add({
  lex: 1,
  id: 'com.example.post',
  defs: {
    // ...
  }
})

// validate
lex.assertValidRecord('com.example.record', {$type: 'com.example.record', ...})
lex.assertValidXrpcParams('com.example.query', {...})
lex.assertValidXrpcInput('com.example.procedure', {...})
lex.assertValidXrpcOutput('com.example.query', {...})
```

## License

This project is dual-licensed under MIT and Apache 2.0 terms:

- MIT license ([LICENSE-MIT.txt](https://github.com/bluesky-social/atproto/blob/main/LICENSE-MIT.txt) or http://opensource.org/licenses/MIT)
- Apache License, Version 2.0, ([LICENSE-APACHE.txt](https://github.com/bluesky-social/atproto/blob/main/LICENSE-APACHE.txt) or http://www.apache.org/licenses/LICENSE-2.0)

Downstream projects and end users may chose either license individually, or both together, at their discretion. The motivation for this dual-licensing is the additional software patent assurance provided by Apache 2.0.

---

### Overview (`packages/ozone/README.md`)

# @atproto/ozone: Bluesky Moderation Service

Backend service for moderating the Bluesky network.

[](https://www.npmjs.com/package/@atproto/ozone)
[](https://github.com/bluesky-social/atproto/actions/workflows/repo.yaml)

## DID cache

The API uses a bounded in-memory DID document cache. Configure its limits with `OZONE_DID_CACHE_MAX_ENTRIES` (default 10,000), `OZONE_DID_CACHE_MAX_BYTES` (default 33,554,432 bytes for serialized documents and DID keys), and `OZONE_DID_CACHE_MAX_DOC_BYTES` (default 65,536 bytes per serialized document). Set any limit to `0` to disable retention. Documents exceeding a limit can still be resolved but are not cached. Restart the API after changing these settings.

## Report tags

Reports automatically add a reason tag such as `report:spam` to their subject by default. Set `OZONE_REPORT_TAGS_DISABLED=true` and restart the API to disable these automatic report tags. Unset or `false` keeps them enabled.

The setting applies to reports submitted through both `com.atproto.moderation.createReport` and `tools.ozone.moderation.emitEvent`.

Language and embed tags, manually applied tags, and existing report tags are unaffected.

## Wildcard label queries

Wildcard patterns in `com.atproto.label.queryLabels` are disabled by default, including `*` and requests mixing wildcard and exact patterns. They return HTTP 400 (`InvalidRequest`) with the message "Support for wildcard label queries is temporarily disabled" before accessing the database. Exact URI queries remain available.

Set `OZONE_LABEL_QUERY_WILDCARDS_ENABLED=true` and restart the API to restore wildcard support. Unset or `false` disables it.

## Historical label replay

`com.atproto.label.subscribeLabels` connections with a cursor share a historical replay budget on each API process. `OZONE_LABEL_STREAM_BACKFILL_RATE` sets the positive integer number of labels per second; the default is 2000. Pages contain at most 500 labels, so brief page-sized bursts are possible. Restart the API after changing the setting.

This bounds bulk replay work while preserving history and cursor ordering. Large backfills take longer, and multiple replaying clients share the budget. Live subscriptions and sequencer polling do not wait for this budget; cutover queries remain bounded by the existing stream buffer limit.

Subscription start and close logs include a connection ID, cursor, proxy socket address/port, User-Agent and forwarded address chain. Close logs add the last sequence, yielded event count and duration. Forwarded headers and User-Agent are attribution hints, not authenticated caller identities; cookies and authorization headers are excluded.

## Moderation query cancellation

`queryEvents` and `queryStatuses` cancel their listing SQL when the HTTP client disconnects, using Kysely's `cancel query` abort strategy. Kysely uses a separate PostgreSQL connection for cancellation and holds the listing connection until SQL and cancellation finish. This adds no query deadline.

## Moderation inbox standing

Set `NEXT_PUBLIC_STRIKE_SUSPENSION_CONFIG` to the same value used by Ozone UI, for example `4:72,8:168,12:336,16:Infinity`. Each entry maps a strike count to suspension hours; `Infinity` represents a permanent suspension. The backend retains the full map and uses the second and third thresholds, sorted by strike count, for `warning` and `atRisk`. The first tier does not change standing. With no configuration, standing derives from the account's enforcement and restrictions; strike thresholds are disabled. Invalid entries fail configuration at startup.

## Moderation inbox start date

Set `OZONE_INBOX_START_AT` on both the API and daemon to an ISO 8601 timestamp with a timezone, for example `2026-10-15T00:00:00.000Z`. Configuration normalizes it to UTC milliseconds and rejects invalid values at startup. Unset exposes all history. A future date keeps history empty until eligible activity occurs; changing the value requires restarting the services.

Reports are visible only when their `createdAt` is at or after the date, even if an older report is later resolved or reopened. The same bound applies to report detail and resolution actions. Subjects require a public action created at or after the date; action counts, first-action dates, latest actions, detail history and report summaries exclude earlier history. Older appeal history is hidden, while eligibility still respects earlier appeals. Inbox appeals cannot target hidden actions or subjects. Moderator previews use the same cutoff.

Account standing and current enforcement remain live: active restrictions, strikes and labels can predate the inbox start. Preferences and seen watermarks do not expose history; they retain their existing behavior. The cutoff is not a read watermark, so an item created exactly at the start date is initially unread.

Notification producers skip activity before the date and skip transitions on reports or appeal subjects that would be hidden. Lists and unread counts independently enforce the date and target visibility, including existing stored notifications. API and daemon must use the same value. Source reports/actions continue to be recorded normally. Moving the date earlier can reveal more source history, but skipped notifications are not backfilled. Complete the writer rollout before the chosen start date to avoid a notification gap.

Report-first pagination requires the `reporterDid` backfill and the post-retirement catch-up sweep to finish before deploying this reader. Pre-create the reporter/time indexes and `idx_moderation_event_inbox_public_created` concurrently in production before the inbox migration. The latter indexes `(subjectDid, createdAt DESC, id DESC)` so subject history can seek the configured date.

## Strike expiry

Clients supply strike amounts and expiry timestamps from severity settings.
The backend stores them and excludes expired strikes from active totals.

Legacy events without expiry require a separate repair. Changing expiry does
not undo takedowns, suspensions, or lifetime strike totals.

## License

This project is dual-licensed under MIT and Apache 2.0 terms:

- MIT license ([LICENSE-MIT.txt](https://github.com/bluesky-social/atproto/blob/main/LICENSE-MIT.txt) or http://opensource.org/licenses/MIT)
- Apache License, Version 2.0, ([LICENSE-APACHE.txt](https://github.com/bluesky-social/atproto/blob/main/LICENSE-APACHE.txt) or http://www.apache.org/licenses/LICENSE-2.0)

Downstream projects and end users may chose either license individually, or both together, at their discretion. The motivation for this dual-licensing is the additional software patent assurance provided by Apache 2.0.

---

### Overview (`packages/pds/README.md`)

# @atproto/pds: Personal Data Server (PDS)

TypeScript reference implementation of an atproto PDS.

[](https://www.npmjs.com/package/@atproto/pds)
[](https://github.com/bluesky-social/atproto/actions/workflows/repo.yaml)

If you are interested in self-hosting a PDS, you probably want this repository instead, which has a thin service wrapper, documentation, a Dockerfile, etc: https://github.com/bluesky-social/pds

## License

This project is dual-licensed under MIT and Apache 2.0 terms:

- MIT license ([LICENSE-MIT.txt](https://github.com/bluesky-social/atproto/blob/main/LICENSE-MIT.txt) or http://opensource.org/licenses/MIT)
- Apache License, Version 2.0, ([LICENSE-APACHE.txt](https://github.com/bluesky-social/atproto/blob/main/LICENSE-APACHE.txt) or http://www.apache.org/licenses/LICENSE-2.0)

Downstream projects and end users may chose either license individually, or both together, at their discretion. The motivation for this dual-licensing is the additional software patent assurance provided by Apache 2.0.

---

### Overview (`packages/repo/README.md`)

# @atproto/repo: Repository and MST

TypeScript library for atproto repositories, and in particular the Merkle Search Tree (MST) data structure.

[](https://www.npmjs.com/package/@atproto/repo)
[](https://github.com/bluesky-social/atproto/actions/workflows/repo.yaml)

Repositories in atproto are signed key/value stores containing CBOR-encoded data records. The structure and implementation details are described in [the specification](https://atproto.com/specs/repository). This includes MST node format, serialization, structural constraints, and more.

## License

This project is dual-licensed under MIT and Apache 2.0 terms:

- MIT license ([LICENSE-MIT.txt](https://github.com/bluesky-social/atproto/blob/main/LICENSE-MIT.txt) or http://opensource.org/licenses/MIT)
- Apache License, Version 2.0, ([LICENSE-APACHE.txt](https://github.com/bluesky-social/atproto/blob/main/LICENSE-APACHE.txt) or http://www.apache.org/licenses/LICENSE-2.0)

Downstream projects and end users may chose either license individually, or both together, at their discretion. The motivation for this dual-licensing is the additional software patent assurance provided by Apache 2.0.

---

### Overview (`packages/sync/README.md`)

# @atproto/sync: atproto sync tools

TypeScript library for syncing data from the [atproto](https://atproto.com) network. Currently only supports firehose (relay) subscriptions

[](https://www.npmjs.com/package/@atproto/sync)
[](https://github.com/bluesky-social/atproto/actions/workflows/repo.yaml)

## Usage

The firehose class will spin up a websocket connection to `com.atproto.sync.subscribeRepos` on a given repo host (by default the Relay run by Bluesky).

Each event will be parsed, authenticated, and then passed on to the supplied `handleEvt` which can handle indexing.

On `Commit` events, the firehose will verify signatures and repo proofs to ensure that the event is authentic. This can be disabled with the `unauthenticatedCommits` flag. Similarly on `Identity` events, the firehose will fetch the latest DID document for the repo and do bidirectional verification on the associated handle. This can be disabled with the `unauthenticatedHandles` flag.

Events of a certain type can be excluded using the `excludeIdentity`/`excludeAccount`/`excludeCommit` flags. And repo writes can be filtered down to specific collections using `filterCollections`. By default, all events are parsed and passed through to the handler. Note that this filtered currently happens client-side, though it is likely we will introduce server-side methods for doing so in the future.

Non-fatal errors that are encountered will be passed to the required `onError` handler. In most cases, these can just be logged.

When using the firehose class, events are processed serially. Each event must be finished being handled before the next one is parsed and authenticated.

```ts
import { Firehose } from '@atproto/sync'
import { IdResolver } from '@atproto/identity'

const idResolver = new IdResolver()
const firehose = new Firehose({
  idResolver,
  service: 'wss://bsky.network',
  handleEvt: async (evt) => {
    if (evt.event === 'identity') {
      // ...
    } else if (evt.event === 'account') {
      // ...
    } else if (evt.event === 'create') {
      // ...
    } else if (evt.event === 'update') {
      // ...
    } else if (evt.event === 'delete') {
      // ...
    }
  },
  onError: (err) => {
    console.error(err)
  },
  filterCollections: ['com.myexample.app'],
})
firehose.start()

// on service shutdown
await firehose.destroy()
```

For more robust indexing pipelines, it's recommended to use the supplied `MemoryRunner` class. This provides an in-memory partitioned queue. As events from a given repo must be processed in order, this allows events to be processed concurrently while still processing events from any given repo serially.

The `MemoryRunner` also tracks an internal cursor based on the last finished consecutive work. This ensures that no events are dropped, although it does mean that some events may occassionally be replayed (if the websocket drops and reconnects) and therefore it's recommended that any indexing logic is idempotent. An optional `setCursor` parameter may be supplied to the `MemoryRunner` which can be used to persistently store the most recently processed cursor.

```ts
import { Firehose, MemoryRunner } from '@atproto/sync'
import { IdResolver } from '@atproto/identity'

const idResolver = new IdResolver()
const runner = new MemoryRunner({
  setCursor: (cursor) => {
    // persist cursor
  },
})
const firehose = new Firehose({
  idResolver,
  runner,
  service: 'wss://bsky.network',
  handleEvt: async (evt) => {
    // ...
  },
  onError: (err) => {
    console.error(err)
  },
})
firehose.start()

// on service shutdown
await firehose.destroy()
await runner.destroy()
```

## License

This project is dual-licensed under MIT and Apache 2.0 terms:

- MIT license ([LICENSE-MIT.txt](https://github.com/bluesky-social/atproto/blob/main/LICENSE-MIT.txt) or http://opensource.org/licenses/MIT)
- Apache License, Version 2.0, ([LICENSE-APACHE.txt](https://github.com/bluesky-social/atproto/blob/main/LICENSE-APACHE.txt) or http://www.apache.org/licenses/LICENSE-2.0)

Downstream projects and end users may chose either license individually, or both together, at their discretion. The motivation for this dual-licensing is the additional software patent assurance provided by Apache 2.0.

---

### Overview (`packages/syntax/README.md`)

# @atproto/syntax: validation helpers for identifier strings

Validation logic for [atproto](https://atproto.com) identifiers - DIDs, Handles, NSIDs, and AT URIs.

[](https://www.npmjs.com/package/@atproto/syntax)
[](https://github.com/bluesky-social/atproto/actions/workflows/repo.yaml)

## Usage

### Handles

Syntax specification: <https://atproto.com/specs/handle>

```typescript
import { isValidHandle, ensureValidHandle, isValidDid } from '@atproto/syntax'

isValidHandle('alice.test') // returns true
ensureValidHandle('alice.test') // returns void

isValidHandle('al!ce.test') // returns false
ensureValidHandle('al!ce.test') // throws

ensureValidDid('did:method:val') // returns void
ensureValidDid(':did:method:val') // throws
```

### NameSpaced IDs (NSID)

Syntax specification: <https://atproto.com/specs/nsid>

```typescript
import { NSID } from '@atproto/syntax'

const id1 = NSID.parse('com.example.foo')
id1.authority // => 'example.com'
id1.name // => 'foo'
id1.toString() // => 'com.example.foo'

const id2 = NSID.create('example.com', 'foo')
id2.authority // => 'example.com'
id2.name // => 'foo'
id2.toString() // => 'com.example.foo'

const id3 = NSID.create('example.com', 'someRecord')
id3.authority // => 'example.com'
id3.name // => 'someRecord'
id3.toString() // => 'com.example.someRecord'

NSID.isValid('com.example.foo') // => true
NSID.isValid('com.example.someRecord') // => true
NSID.isValid('example.com/foo') // => false
NSID.isValid('foo') // => false
```

### AT URI

Syntax specification: <https://atproto.com/specs/at-uri-scheme>

```typescript
import { AtUri } from '@atproto/syntax'

const uri = new AtUri('at://bob.com/com.example.post/1234')
uri.protocol // => 'at:'
uri.origin // => 'at://bob.com'
uri.hostname // => 'bob.com'
uri.collection // => 'com.example.post'
uri.rkey // => '1234'
```

## License

This project is dual-licensed under MIT and Apache 2.0 terms:

- MIT license ([LICENSE-MIT.txt](https://github.com/bluesky-social/atproto/blob/main/LICENSE-MIT.txt) or http://opensource.org/licenses/MIT)
- Apache License, Version 2.0, ([LICENSE-APACHE.txt](https://github.com/bluesky-social/atproto/blob/main/LICENSE-APACHE.txt) or http://www.apache.org/licenses/LICENSE-2.0)

Downstream projects and end users may chose either license individually, or both together, at their discretion. The motivation for this dual-licensing is the additional software patent assurance provided by Apache 2.0.

---

### Overview (`packages/tap/README.md`)

# @atproto/tap

TypeScript client library for [Tap](https://github.com/bluesky-social/indigo/tree/main/cmd/tap/README.md), a sync utility for the AT Protocol network.

Tap handles firehose connections, cryptographic verification, backfill, and filtering. This client library lets you connect to a Tap instance and receive simple JSON events for the repos you care about.

[](https://www.npmjs.com/package/@atproto/tap)
[](https://github.com/bluesky-social/atproto/actions/workflows/repo.yaml)

## Quick Start

```bash
npm install @atproto/tap
```

```ts
import { Tap, SimpleIndexer } from '@atproto/tap'

const tap = new Tap('http://localhost:2480', { adminPassword: 'secret' })

const indexer = new SimpleIndexer()

indexer.identity(async (evt) => {
  console.log(`${evt.did} updated identity: ${evt.handle} (${evt.status})`)
})

indexer.record(async (evt) => {
  const uri = `at://${evt.did}/${evt.collection}/${evt.rkey}`
  if (evt.action === 'create' || evt.action === 'update') {
    console.log(`${evt.action}: ${uri}`)
  } else {
    console.log(`deleted: ${uri}`)
  }
})

indexer.error((err) => console.error(err))

const channel = tap.channel(indexer)
channel.start()

await tap.addRepos(['did:plc:ewvi7nxzyoun6zhxrhs64oiz'])

// On shutdown
await channel.destroy()
```

## Running Tap

See the [Tap README](https://github.com/bluesky-social/indigo/tree/main/cmd/tap/README.md) for details getting Tap up and running. Your app can communicate with it either locally or over the internet.

This library is intended to be used with Tap running in the default mode of "WebScoket with acks". In this mode, Tap provides:

- **At-least-once delivery**: Events may be redelivered if the connection drops before an ack is received
- **Per-repo ordering**: Events for the same repo are delivered in order
- **Backfill**: When you add a repo, historical events are delivered before live events

## API

### `Tap`

The main client for interacting with a Tap server.

```ts
const tap = new Tap(url: string, config?: TapConfig)
```

**Config options:**

- `adminPassword?: string` - Password for Basic auth (required if Tap server has auth enabled)

**Methods:**

- `channel(handler: TapHandler, opts?: TapWebsocketOptions): TapChannel` - Create a WebSocket channel to receive events
- `addRepos(dids: string[]): Promise<void>` - Add repos to track (triggers backfill)
- `removeRepos(dids: string[]): Promise<void>` - Stop tracking repos
- `resolveDid(did: string): Promise<DidDocument | null>` - Resolve a DID to its DID document
- `getRepoInfo(did: string): Promise<RepoInfo>` - Get info about a tracked repo

### `TapChannel`

WebSocket connection for receiving events. Created via `tap.channel()`.

```ts
const channel = tap.channel(handler, opts?)
```

**Methods:**

- `start(): Promise<void>` - Start receiving events. Returns a promise that resolves when the connection is destroyed or errors.
- `destroy(): Promise<void>` - Close the connection

The channel automatically handles reconnection and keepalive. Events are automatically acknowledged after your handler completes successfully.

### `SimpleIndexer`

A convenience class for handling events by type. Passed into `tap.channel()` when opening a channel with Tap.

```ts
const indexer = new SimpleIndexer()

indexer.identity(async (evt: IdentityEvent) => { ... })
indexer.record(async (evt: RecordEvent) => { ... })
indexer.error((err: Error) => { ... })
```

If no error handler is registered, errors will throw as unhandled exceptions.

### `LexIndexer`

A typed indexer that uses `@atproto/lex` schemas for type-safe event handling. Register handlers for specific record types and actions:

```ts
import { LexIndexer } from '@atproto/tap'
import * as com from './lexicons/com'

const indexer = new LexIndexer()

// Handle creates for a specific record type
indexer.create(com.example.post, async (evt) => {
  // evt.record is fully typed as com.example.post.Main
  console.log(`New post: ${evt.record.text}`)
})

// Handle updates
indexer.update(com.example.post, async (evt) => {
  console.log(`Updated post: ${evt.record.text}`)
})

// Handle deletes (no record on delete events)
indexer.delete(com.example.post, async (evt) => {
  console.log(`Deleted: at://${evt.did}/${evt.collection}/${evt.rkey}`)
})

// Handle both creates and updates with put()
indexer.put(com.example.like, async (evt) => {
  console.log(`Like ${evt.action}: ${evt.record.subject.uri}`)
})

// Fallback for unhandled record types/actions
indexer.other(async (evt) => {
  console.log(`Unhandled: ${evt.action}, ${evt.collection}`)
})

// Identity and error handlers
indexer.identity(async (evt) => { ... })
indexer.error((err) => { ... })

const channel = tap.channel(indexer)
```

Records are validated against their schemas before handlers are called. If validation fails, an error is thrown which will be picked up through the `error` handler..

Duplicate handler registration throws an error, including conflicts between `put()` and `create()`/`update()` for the same schema.

### `TapHandler`

You can create your own custom handler by creating a class that implements the `TapHandler` interface:

```ts
interface TapHandler {
  onEvent: (evt: TapEvent, opts: HandlerOpts) => void | Promise<void>
  onError: (err: Error) => void
}

interface HandlerOpts {
  signal: AbortSignal
  ack: () => Promise<void>
}
```

When implementing a custom handler, be sure to call `ack()` when you're done processing the event.

## Event Types

### `RecordEvent`

```ts
type RecordEvent = {
  id: number
  type: 'record'
  action: 'create' | 'update' | 'delete'
  did: string
  rev: string
  collection: string
  rkey: string
  record?: Record<string, unknown> // present for create/update
  cid?: string // present for create/update
  live: boolean // true if from firehose, false if from backfill
}
```

### `IdentityEvent`

```ts
type IdentityEvent = {
  id: number
  type: 'identity'
  did: string
  handle: string
  isActive: boolean
  status: 'active' | 'takendown' | 'suspended' | 'deactivated' | 'deleted'
}
```

## Webhook Mode

If your Tap server is configured for webhook delivery, you can use `parseTapEvent` to validate incoming webhook payloads:

```ts
import express from 'express'
import { parseTapEvent, assureAdminAuth } from '@atproto/tap'

const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD

const app = express()
app.use(express.json())

app.post('/webhook', async (req, res) => {
  try {
    assureAdminAuth(ADMIN_PASSWORD, req.headers.authorization)
  } catch {
    return res.status(401).json({ error: 'Unauthorized' })
  }

  try {
    const evt = parseTapEvent(req.body)
    // handle event...
    res.sendStatus(200)
  } catch (err) {
    console.error('Failed to process event:', err)
    res.status(500).json({ error: 'Failed to process event' })
  }
})
```

## Utilities

### Auth helpers

```ts
import {
  formatAdminAuthHeader,
  parseAdminAuthHeader,
  assureAdminAuth,
} from '@atproto/tap'

// Format a password into a Basic auth header value
const header = formatAdminAuthHeader('secret')
// => 'Basic YWRtaW46c2VjcmV0'

// Parse an auth header to extract the password (throws if invalid)
const password = parseAdminAuthHeader(header)

// Verify auth header matches expected password (timing-safe, throws if invalid)
assureAdminAuth('secret', req.headers.authorization)
```

### Event parsing

```ts
import { parseTapEvent } from '@atproto/tap'

const evt = parseTapEvent(jsonData) // validates and returns typed TapEvent
```

## License

MIT

---

### Overview (`packages/ws-client/README.md`)

# @atproto/ws-client: WebSocket Client Library

A WebSocket client hardened for long-lived streams

[](https://www.npmjs.com/package/@atproto/ws-client)
[](https://github.com/bluesky-social/atproto/actions/workflows/repo.yaml)

## Install

```sh
npm install @atproto/ws-client
```

## Overview

The package provides a WebSocket client `websocket(url, options?)` as an async iterable. It connects, reads messages, reconnects on failure, and yields one continuous stream.

Beyond reading, it covers the concerns a long-lived stream runs into in practice: liveness (a heartbeat and an idle timeout to detect dead connections), flow control (real socket backpressure on Node.js, plus a hard cap on buffered bytes), reconnect classification by close code and error type, and lifecycle hooks. It works on Node.js and in the browser.

```ts
import { websocket } from '@atproto/ws-client'

for await (const message of websocket('wss://example.com/feed')) {
  console.log(message)
}
```

The loop runs across reconnects: if the connection drops and a retry succeeds, iteration continues, and the consumer never sees the gap as a separate stream. A message is a `string` or `Uint8Array` depending on the frame's wire type, unless narrowed by `dataMode` (see below).

### `dataMode`

`dataMode: 'auto' | 'text' | 'binary'` (default `'auto'`) both types what's yielded and is enforced at runtime:

```ts
// message: string
for await (const message of websocket(url, { dataMode: 'text' })) { ... }

// message: Uint8Array
for await (const message of websocket(url, { dataMode: 'binary' })) { ... }
```

If the server sends a frame of the wrong type under `'text'` or `'binary'`, the stream ends with a `DataModeError` (non-retryable — a protocol mismatch will recur) rather than silently coercing the data.

## Lifecycle

A stream ends in one of two ways: you `break` (or `return`/`throw`) out of the loop, or you abort a `signal` passed in `options`.

```ts
const controller = new AbortController()
const gen = websocket(url, { signal: controller.signal })

// later, from elsewhere:
controller.abort()
```

Stopping always closes the socket politely — it's a request to stop, not a failure — and the abort reason is how you choose the close code:

| how you stop                          | what the peer sees           |
| ------------------------------------- | ---------------------------- |
| `break`/`throw` in the loop           | close `1000`                 |
| `controller.abort()` (no argument)    | close `1000`                 |
| `controller.abort(new CloseError(…))` | close with that error's code |
| `controller.abort(anythingElse)`      | close `1000`                 |

The last row matters for shutdown paths: `controller.abort(new Error('SIGTERM'))` is a deliberate stop, so the peer still gets a clean goodbye. The reason isn't lost — it's what the iterator rejects with. `break` and `throw` are indistinguishable to a generator, so they share the plain clean close.

**In the browser, a `CloseError`'s code carries through only if the platform permits it.** The WHATWG API accepts `1000` and the private-use range `3000`–`4999`, and throws on anything else — the rest of the RFC 6455 codes are reserved for the protocol itself to send, even though an endpoint may legitimately receive them. A code outside that set closes with `1000` instead, so pick from `3000`–`4999` for application-specific codes that need to reach a browser peer. Node.js has no such restriction and sends any code as given.

A non-reconnectable `1000` close — the server says it's done and the reconnect policy declines to retry — ends the stream normally: the loop just finishes, same as `break`. Every other end rejects the iterator, including a fatal protocol close such as `1002`, a network failure, or an aborted signal. Wrap the loop in `try`/`catch` if you need to distinguish "the peer said goodbye" from "something went wrong."

```ts
try {
  for await (const message of websocket(url)) {
    handle(message)
  }
  // stream ended cleanly
} catch (err) {
  // stream ended abnormally
}
```

## Reconnects

By default a dropped connection is retried with jittered exponential backoff, capped by `maxReconnectSeconds` (default `64`), and the backoff resets to its base after each connection that opens.

- `url` may be a function: `() => string | URL | Promise<string | URL>`. It's re-invoked on every attempt, including reconnects — the idiom for resuming a stream at a cursor (see the firehose example below).
- `shouldReconnect` controls whether a failure is retried:
  - `true` (default) — the built-in policy: each typed error self-classifies, and close codes are classified per RFC 6455.
  - `false` — never reconnect; the first end is terminal.
  - `(error, attempt) => boolean` — replaces the default classification.

```ts
websocket(url, {
  shouldReconnect: (error, attempt) => attempt < 5,
})
```

Hooks observe the lifecycle at two levels. `onOpen()` and `onClose(detail)` bookend the **stream**, once each; `onConnect(sender)` and `onDisconnect()` bookend each **connection**, as many times as it takes.

| hook                              | when                                                        |
| --------------------------------- | ----------------------------------------------------------- |
| `onOpen()`                        | the stream is live, once, just before the first `onConnect` |
| `onConnect(sender)`               | a connection is up — including the first                    |
| `onDisconnect()`                  | that connection ended                                       |
| `onReconnect(error, { attempt })` | a connection failed and a retry is coming                   |
| `onError(error)`                  | a connection failed and the stream is giving up             |
| `onClose(detail)`                 | the stream ended, once, after the local socket has closed   |

`onReconnect` and `onError` are exclusive, and the split matters: an `onReconnect` error is swallowed by the retry, so that hook is the _only_ place transient trouble surfaces. An `onError` error is also what the iterator rejects with, so you can handle it there or in a `catch` around the loop.

`onConnect` and `onDisconnect` pair exactly, and a dial that never connected produces neither. So a stream stuck retrying reports one `onDisconnect` for the connection it lost and an `onReconnect` per failed attempt.

`onDisconnect`, not `onReconnect`, is where to stop using a `sender`: the loop only advances when the consumer pulls, so `onReconnect` can arrive well after the socket died.

`onClose` fires only once the local socket is closed, so it's safe to treat as "teardown is done" and release whatever the stream depended on. The end of the `for await` carries the same guarantee: iteration doesn't settle until the socket is down, so awaiting the loop is enough — you don't need the hook to sequence a shutdown.

A polite close waits for the peer to answer, so on Node.js that wait is capped at one second (after which the socket is destroyed and reported as an abnormal `1006`) rather than the 30 seconds `ws` allows by default.

`wasClean: true` means the closing handshake completed — both sides exchanged close frames — not that the close was a `1000`. A `1002` protocol close is reported clean, and still rejects the iterator; the reconnect policy keys on the close code, never on this flag.

## Liveness

Two independent mechanisms detect a dead connection that hasn't told you so:

- **Heartbeat** (Node.js only): `heartbeat: { intervalMs }` pings each interval and accepts any inbound frame, not just a pong, as evidence of life. A missed round becomes a `HeartbeatTimeoutError` and a reconnect. The browser's WebSocket API has no ping/pong, so this option is ignored there.
- **Idle timeout** (both platforms): `idleTimeoutMs` ends the connection with an `IdleTimeoutError` if no message arrives within the window. This is the browser's only dead-connection detector, so set it there if you need one.

Only the Node case is on by default: `heartbeat` defaults to a 10s interval (pass `heartbeat: false` to disable, or `{ intervalMs }` to change it), while `idleTimeoutMs` is off unless you set it.

**A connection that fails discards whatever it had buffered but undelivered.** A liveness timeout, a byte-cap overflow, or a transport error drops those messages rather than yielding data from a connection already known to be broken; a _clean_ close still drains what arrived before it. For cursor-based streams that's harmless, since a reconnect resumes from the cursor. If your stream isn't resumable, keep `highWaterMark` low enough that little is ever in flight.

## Flow control

- **Node.js**: real socket backpressure. Past `highWaterMark` (default 1 MiB of buffered, unread bytes) the socket is paused until the consumer catches up.
- **Both platforms**: `maxBufferedBytes` is a hard cap. Exceeding it fails the connection with a `BufferOverflowError` (non-retryable) rather than growing the buffer without bound. It's the _only_ backstop in the browser, since the WHATWG API gives no way to pause a socket.

Both thresholds count **received, unread** bytes — there is only one buffer in this library, and nothing bounds the write side. They count binary frames exactly and **over-estimate text**: a string is measured as UTF-16 code units × 2, which avoids an encode per message but counts mostly-ASCII text at roughly twice its wire size. Both are safety valves, so pausing or failing early is the safer direction to err — just don't read them as exact wire bytes when sizing them for a text stream.

Overflow is fatal by default, on the reasoning that a consumer which fell behind once will fall behind again. If your stream is resumable — a cursor-based feed, say — you can opt into retrying it, which is worth considering in the browser where there's no backpressure to lean on:

```ts
import {
  BufferOverflowError,
  defaultShouldReconnect,
  websocket,
} from '@atproto/ws-client'

websocket(url, {
  maxBufferedBytes: 10 * 1024 * 1024,
  shouldReconnect: (error) =>
    error instanceof BufferOverflowError || defaultShouldReconnect(error),
})
```

Whatever was buffered is discarded, so only reconnect on overflow if you can resume from where you left off.

## Compression

`permessage-deflate` is offered by default on both platforms. There's no option to configure or disable it.

## Sending

`websocket()` yields messages; sending goes through the `sender` handed to `onConnect`. A sender belongs to one connection and rejects once that connection ends, so always use the most recent one rather than retaining an older:

```ts
import { type Sender, websocket } from '@atproto/ws-client'

let sender: Sender<'text'> | undefined

const stream = websocket(url, {
  dataMode: 'text',
  onConnect: (s) => {
    sender = s
  },
  onDisconnect: () => {
    sender = undefined
  },
})

for await (const message of stream) {
  const event = JSON.parse(message)
  await handle(event)
  await sender?.send(JSON.stringify({ type: 'ack', id: event.id }))
}
```

`send()` resolves on hand-off and not on delivery: flushed on Node.js or accepted by the browser's WebSocket.

If you need at-least-once delivery, handle that within the application. If a message needs to be sent when there isn't an active sender, it can be queued then later flushed from `onConnect`.

## Headers (Node.js only)

```ts
websocket(url, { headers: { Authorization: `Bearer ${token}` } })
```

`headers` applies to the connection's upgrade request. It's Node.js-only: the WHATWG API used in the browser has no way to set request headers, so passing `headers` there throws at construction rather than silently dropping what is usually an auth credential. `BrowserWebSocketOptions` omits the field entirely, so it's also a compile-time error if you import the browser-specific option type.

## Errors

Every failure is a `WebSocketClientError`, and each subclass classifies itself via `shouldRetry()` — which is what the default reconnect policy consults:

| Error                   | Meaning                                                    | Retryable by default                |
| ----------------------- | ---------------------------------------------------------- | ----------------------------------- |
| `CloseError`            | The connection closed with a given code/reason/cleanliness | Classified by close code (RFC 6455) |
| `SocketError`           | A transport-level failure (e.g. connection refused)        | Yes                                 |
| `HeartbeatTimeoutError` | No frame observed within the heartbeat window (Node.js)    | Yes                                 |
| `IdleTimeoutError`      | No message arrived within `idleTimeoutMs`                  | Yes                                 |
| `BufferOverflowError`   | Buffered bytes exceeded `maxBufferedBytes`                 | No                                  |
| `DataModeError`         | A frame's type didn't match `dataMode`                     | No                                  |

A bare `WebSocketClientError` marks misuse of the API itself, like sending on a closed connection, and is never retryable.

## Examples

### Resuming a firehose from a cursor

A function-valued `url` is re-invoked on every attempt, including reconnects. Read the latest cursor at call time so a reconnect resumes where the last connection left off instead of restarting the stream:

```ts
import { websocket } from '@atproto/ws-client'

let cursor: string | undefined

const stream = websocket(() => {
  const url = new URL('wss://example.com/subscribe')
  if (cursor) url.searchParams.set('cursor', cursor)
  return url
})

for await (const message of stream) {
  cursor = extractCursor(message)
  handle(message)
}
```

### A browser client with an idle timeout

The browser has no heartbeat mechanism, so `idleTimeoutMs` is its only way to detect a connection that has silently gone dead:

```ts
import { websocket } from '@atproto/ws-client'

try {
  for await (const message of websocket('wss://example.com/feed', {
    idleTimeoutMs: 30_000,
  })) {
    render(message)
  }
} catch (err) {
  showDisconnected(err)
}
```

## A note for library authors: keep this package external

This package selects its transport (Node vs. browser) via a conditional `imports` entry (`#transport`) that the runtime resolves at import time. If you pre-bundle it into a library's output, that resolution collapses to whichever transport your bundler's target picked, and the result won't adapt to the environment it actually runs in. Mark `@atproto/ws-client` as external so each consumer's own runtime resolves the transport.

## License

This project is dual-licensed under MIT and Apache 2.0 terms:

- MIT license ([LICENSE-MIT.txt](https://github.com/bluesky-social/atproto/blob/main/LICENSE-MIT.txt) or http://opensource.org/licenses/MIT)
- Apache License, Version 2.0, ([LICENSE-APACHE.txt](https://github.com/bluesky-social/atproto/blob/main/LICENSE-APACHE.txt) or http://www.apache.org/licenses/LICENSE-2.0)

Downstream projects and end users may chose either license individually, or both together, at their discretion. The motivation for this dual-licensing is the additional software patent assurance provided by Apache 2.0.

---

### Overview (`packages/xrpc-server/README.md`)

# @atproto/xrpc-server: atproto HTTP API server library

TypeScript library for implementing [atproto](https://atproto.com) HTTP API services, with Lexicon schema validation.

[](https://www.npmjs.com/package/@atproto/xrpc-server)
[](https://github.com/bluesky-social/atproto/actions/workflows/repo.yaml)

## Usage

```typescript
import { LexiconDoc } from '@atproto/lexicon'
import * as xrpc from '@atproto/xrpc-server'
import express from 'express'

const lexicons: LexiconDoc[] = [
  {
    lexicon: 1,
    id: 'io.example.ping',
    defs: {
      main: {
        type: 'query',
        parameters: {
          type: 'params',
          properties: { message: { type: 'string' } },
        },
        output: {
          encoding: 'application/json',
        },
      },
    },
  },
]

// create xrpc server
const server = xrpc.createServer(lexicons)

function ping(ctx: {
  auth: xrpc.HandlerAuth | undefined
  params: xrpc.Params
  input: xrpc.HandlerInput | undefined
  req: express.Request
  res: express.Response
}) {
  return { encoding: 'application/json', body: { message: ctx.params.message } }
}

server.method('io.example.ping', ping)

// mount in express
const app = express()
app.use(server.router)
app.listen(8080)
```

## License

This project is dual-licensed under MIT and Apache 2.0 terms:

- MIT license ([LICENSE-MIT.txt](https://github.com/bluesky-social/atproto/blob/main/LICENSE-MIT.txt) or http://opensource.org/licenses/MIT)
- Apache License, Version 2.0, ([LICENSE-APACHE.txt](https://github.com/bluesky-social/atproto/blob/main/LICENSE-APACHE.txt) or http://www.apache.org/licenses/LICENSE-2.0)

Downstream projects and end users may chose either license individually, or both together, at their discretion. The motivation for this dual-licensing is the additional software patent assurance provided by Apache 2.0.

---

### Overview (`packages/xrpc/README.md`)

# @atproto/xrpc: atproto HTTP API Client

TypeScript client library for talking to [atproto](https://atproto.com) services, with Lexicon schema validation.

[](https://www.npmjs.com/package/@atproto/xrpc)
[](https://github.com/bluesky-social/atproto/actions/workflows/repo.yaml)

> [!NOTE]
>
> We recommend using [lex](https://github.com/bluesky-social/atproto/tree/main/packages/lex/lex), our type-safe SDK with Lexicon code generation, for all new projects.

## Usage

```typescript
import { LexiconDoc } from '@atproto/lexicon'
import { XrpcClient } from '@atproto/xrpc'

const pingLexicon = {
  lexicon: 1,
  id: 'io.example.ping',
  defs: {
    main: {
      type: 'query',
      description: 'Ping the server',
      parameters: {
        type: 'params',
        properties: { message: { type: 'string' } },
      },
      output: {
        encoding: 'application/json',
        schema: {
          type: 'object',
          required: ['message'],
          properties: { message: { type: 'string' } },
        },
      },
    },
  },
} satisfies LexiconDoc

const xrpc = new XrpcClient('https://ping.example.com', [
  // Any number of lexicon here
  pingLexicon,
])

const res1 = await xrpc.call('io.example.ping', {
  message: 'hello world',
})
res1.encoding // => 'application/json'
res1.body // => {message: 'hello world'}
```

### With a custom fetch handler

```typescript
import { XrpcClient } from '@atproto/xrpc'

const session = {
  serviceUrl: 'https://ping.example.com',
  token: '<my-token>',
  async refreshToken() {
    const { token } = await fetch('https://auth.example.com/refresh', {
      method: 'POST',
      headers: { Authorization: `Bearer ${this.token}` },
    }).then((res) => res.json())

    this.token = token

    return token
  },
}

const sessionBasedFetch: FetchHandler = async (
  url: string,
  init: RequestInit,
) => {
  const headers = new Headers(init.headers)

  headers.set('Authorization', `Bearer ${session.token}`)

  const response = await fetch(new URL(url, session.serviceUrl), {
    ...init,
    headers,
  })

  if (response.status === 401) {
    // Refresh token, then try again.
    const newToken = await session.refreshToken()
    headers.set('Authorization', `Bearer ${newToken}`)
    return fetch(new URL(url, session.serviceUrl), { ...init, headers })
  }

  return response
}

const xrpc = new XrpcClient(sessionBasedFetch, [
  // Any number of lexicon here
  pingLexicon,
])

//
```

## License

This project is dual-licensed under MIT and Apache 2.0 terms:

- MIT license ([LICENSE-MIT.txt](https://github.com/bluesky-social/atproto/blob/main/LICENSE-MIT.txt) or http://opensource.org/licenses/MIT)
- Apache License, Version 2.0, ([LICENSE-APACHE.txt](https://github.com/bluesky-social/atproto/blob/main/LICENSE-APACHE.txt) or http://www.apache.org/licenses/LICENSE-2.0)

Downstream projects and end users may chose either license individually, or both together, at their discretion. The motivation for this dual-licensing is the additional software patent assurance provided by Apache 2.0.

---

### Overview (`services/bsky/README.md`)

# bsky appview service

This is the service entrypoint for the bsky appview. The entrypoint command should run `api.js` with node, e.g. `node api.js`. The following env vars are supported:

- `BSKY_PUBLIC_URL` - (required) the public url of the appview, e.g. `https://api.bsky.app`.
- `BSKY_DID_PLC_URL` - (required) the url of the PLC service used for looking up did documents, e.g. `https://plc.directory`.
- `BSKY_DATAPLANE_URL` - (required) the url where the backing dataplane service lives.
- `BSKY_SERVICE_SIGNING_KEY` - (required) the public signing key in the form of a `did:key`, used for service-to-service auth. Advertised in the appview's `did:web` document.
- `BSKY_ADMIN_PASSWORDS` - (alt. `BSKY_ADMIN_PASSWORD`) (required) comma-separated list of admin passwords used for role-based auth.
- `NODE_ENV` - (recommended) for production usage, should be set to `production`. Otherwise all responses are validated on their way out. There may be other effects of not setting this to `production`, as dependencies may also implement debug modes based on its value.
- `BSKY_VERSION` - (recommended) version of the bsky service. This is advertised by the health endpoint.
- `BSKY_PORT` - (recommended) the port that the service will run on.
- `BSKY_IMG_URI_ENDPOINT` - (recommended) the base url for resized images, e.g. `https://cdn.bsky.app/img`. When not set, sets-up an image resizing service directly on the appview.
- `BSKY_SERVER_DID` - (recommended) the did of the appview service. When this is a `did:web` that matches the appview's public url, a `did:web` document is served.
- `BSKY_HANDLE_RESOLVE_NAMESERVERS` - alternative domain name servers used for handle resolution, comma-separated.
- `BSKY_BLOB_CACHE_LOC` - when `BSKY_IMG_URI_ENDPOINT` is not set, this determines where resized blobs are cached by the image resizing service.
- `BSKY_COURIER_URL` - URL of courier service.
- `BSKY_COURIER_API_KEY` - API key for courier service.
- `BSKY_BSYNC_URL` - URL of bsync service.
- `BSKY_BSYNC_API_KEY` - API key for bsync service.
- `BSKY_SEARCH_URL` - (alt. `BSKY_SEARCH_ENDPOINT`) -
- `BSKY_LABELS_FROM_ISSUER_DIDS` - comma-separated list of labelers to always use for record labels.
- `MOD_SERVICE_DID` - the DID of the mod service, used to receive service authed requests.

---

### Overview (`packages/internal/fetch-node/README.md`)

# Node.js fetch utilities

Fetch helpers for Node.js 22+ that help protect services when fetching URLs supplied by users.

## Usage

Create a fetch wrapper once and reuse it:

```js
import { safeFetchWrap } from '@atproto-labs/fetch-node'

const safeFetch = safeFetchWrap()
const response = await safeFetch('https://api.github.com/repos/nodejs/node', {
  redirect: 'follow',
})
const data = await response.json()
```

By default, `safeFetchWrap` allows HTTPS on port 443, blocks private and reserved IP addresses and a list of forbidden domains, sets a 10-second timeout, and limits response bodies to 512 KiB. Destination checks also apply to redirects, and IP addresses are checked when connecting.

Pass `redirect` explicitly on each call: use `'follow'`, `'manual'`, or `'error'`. Configure the wrapper with options such as `timeout` (milliseconds), `responseMaxSize` (bytes), and `forbiddenDomainNames`. If you supply a custom `fetch`, it must honor Undici's `dispatcher` option; do not pass a separate dispatcher on individual calls.

## Other helpers

- `unicastFetchWrap()` restricts destinations to public IP addresses, including redirects, without adding the HTTPS, timeout, or response-size rules.
- `safeDispatchFetchWrap({ checkUrl, lookup })` lets you supply your own destination checks. `checkUrl` receives the origin of each request, including redirects, and returns a reason string to block it or `undefined` to allow it.
- `unicastLookup` checks DNS results before connecting. `isUnicastIpHostname(hostname)` checks a literal IP address and returns `undefined` for a domain name.
- This package also exports the helpers from `@atproto-labs/fetch` for working with requests, responses, errors, and fetch wrappers.

## Temporary eMudhra TLS mitigation

`emudhra-preload` blocks HTTPS connections that use any of the eight intermediate keys affected by the [eMudhra compromise](https://emudhra.we-have-a-problem.com/), as listed in [nodejs/node#66871](https://github.com/nodejs/node/pull/66871). It keeps the existing trusted roots, so other intermediates under those roots still work. Remove it once you deploy Node.js with the fix.

Node supports multiple `--import` flags. Load this before tracing and application code:

```sh
node --import=@atproto-labs/fetch-node/emudhra-preload --import=@atproto/pds/telemetry service.js
```

Or copy [`src/emudhra-preload.mjs`](src/emudhra-preload.mjs) into your image and load it directly. It needs no other files or packages:

```sh
node --import=/opt/mitigations/emudhra-preload.mjs --import=./tracing.mjs service.js
```

You can also add `--import=/opt/mitigations/emudhra-preload.mjs` to `NODE_OPTIONS`. Restart every process and discard any TLS sessions saved before the preload was installed. Resumed sessions skip the check.

The preload works with Node's HTTPS client, built-in `fetch()`, and Undici's standard connectors, including custom CAs and hostname checks. It wraps `tls.connect()` and adds the key check to the client's existing certificate check. Blocked connections fail with `CERT_REVOKED` before any HTTP request data is sent.

It checks keys during full TLS handshakes and caches up to 256 certificate results to avoid repeated work. Normal certificate validation still runs. Reusing a connection or resuming a checked session adds no key-check work.

Limits:

- Keep certificate verification enabled: do not set `rejectUnauthorized: false` or `NODE_TLS_REJECT_UNAUTHORIZED=0`.
- Clients must use `tls.connect()` after the preload runs. Other TLS libraries, direct `TLSSocket` use, and incoming client certificates are not covered. `--require` runs before `--import`, so avoid opening connections or saving a reference to `tls.connect` there.
- Workers and forked processes normally inherit the preload. Keep it enabled if you override `execArgv` or `NODE_OPTIONS`.
- A server can also be blocked if it sends an unused certificate with one of the eight keys. Unlike Node's native fix, this preload cannot check only the certificate path OpenSSL chose.

---

### Overview (`packages/internal/handle-resolver/README.md`)

# Universal Handle Resolver implementation for ATPROTO

This package provides a handle resolver implementation for ATPROTO. It is used
to resolve handles to their corresponding DID.

This package is meant to be used in any JavaScript environment that support the
`fetch()` function. Because APTORO handle resolution requires DNS resolution,
you will need to provide your own DNS resolution function when using this
package.

There are two main classes in this package:

- `AtprotoHandleResolver` This implements the official ATPROTO handle resolution
  algorithm (and requires a DNS resolver).
- `AppViewHandleResolver` This uses HTTP requests to the Bluesky AppView
  (bsky.app) to provide handle resolution.

## Usage

### From a front-end app

Since the ATPROTO handle resolution algorithm requires DNS resolution, and the
browser does not provide a built-in DNS resolver, this package offers two
options:

- Delegate handle resolution to an AppView (`AppViewHandleResolver`). This is
  the recommended approach for front-end apps.
- Use a DNS-over-HTTPS (DoH) server (`DohHandleResolver`). Prefer this method
  if you don't own an AppView and already have a DoH server that you trust.

Using an AppView:

> [!CAUTION]
> Use the Bluesky owned AppView (`https://api.bsky.app/`), or PDS
> (`https://bsky.social/`), at your own risk. Using these servers in a
> third-party application might expose your users' data (IP address) to Bluesky.
> Bluesky might log the data sent to it when your app is resolving handles.
> Bluesky might also change the API, or terms or use, at any time without
> notice. Make sure you are compliant with the Bluesky terms of use as well as
> any laws and regulations that apply to your use case.

```ts
import { AppViewHandleResolver } from '@atproto-labs/handle-resolver'

const resolver = new AppViewHandleResolver({
  service: 'https://my-app-view.com/',
})
const did = await resolver.resolve('my-handle.bsky.social')
```

Using DNS-over-HTTPS (DoH) for DNS resolution:

> [!CAUTION]
> Using a DoH server that you don't own might expose your users' data to
> the DoH server provider. The DoH server provider might log the data sent to it
> by your app, allowing them to track which handles are being resolved by your
> users. In the browser, it is recommended to use a DoH server that you own and
> control. Or to implement your own AppView and use the `AppViewHandleResolver`
> class.

> [!NOTE]
> Using the `DohHandleResolver` requires a DNS-over-HTTPS server that
> supports the DNS-over-HTTPS protocol with "application/dns-json" responses.

```ts
import { DohHandleResolver } from '@atproto-labs/handle-resolver'

// Also works with 'https://cloudflare-dns.com/dns-query'
const resolver = new DohHandleResolver('https://dns.google/resolve', {
  // Optional: Custom fetch function that will be used both for DNS resolution
  // and well-known resolution.
  fetch: globalThis.fetch.bind(globalThis),
})

const did = await resolver.resolve('my-handle.bsky.social')
```

### From a Node.js app

> [!NOTE]
> On a Node.js backend, you will probably want to use the
> "@atproto-labs/handle-resolver-node" package. The example below applies to
> Node.js code running on a user's machine (e.g. through Electron).

```ts
import { AtprotoHandleResolver } from '@atproto-labs/handle-resolver'
import { resolveTxt } from 'node:dns/promises'

const resolver = new AtprotoHandleResolver({
  // Optional: Custom fetch function (used for well-known resolution)
  fetch: globalThis.fetch.bind(globalThis),

  resolveTxt: async (domain: string) =>
    resolveTxt(domain).then((chunks) => chunks.join('')),
})
```

### Caching

Using a default, in-memory cache, in which items expire after 10 minutes:

```ts
import {
  AppViewHandleResolver,
  CachedHandleResolver,
  HandleResolver,
  HandleCache,
} from '@atproto-labs/handle-resolver'

// See previous examples for creating a resolver
declare const sourceResolver: HandleResolver

const resolver = new CachedHandleResolver(sourceResolver)
const did = await resolver.resolve('my-handle.bsky.social')
const did = await resolver.resolve('my-handle.bsky.social') // Result from cache
const did = await resolver.resolve('my-handle.bsky.social') // Result from cache
```

Using a custom cache:

```ts
import {
  AppViewHandleResolver,
  CachedHandleResolver,
  HandleResolver,
  HandleCache,
} from '@atproto-labs/handle-resolver'

// See previous examples for creating a resolver
declare const sourceResolver: HandleResolver

const cache: HandleCache = {
  set(handle, did): Promise<void> {
    /* TODO */
  },
  get(handle): Promise<undefined | string> {
    /* TODO */
  },
  del(handle): Promise<void> {
    /* TODO */
  },
}

const resolver = new CachedHandleResolver(sourceResolver, cache)
const did = await resolver.resolve('my-handle.bsky.social')
const did = await resolver.resolve('my-handle.bsky.social') // Result from cache
const did = await resolver.resolve('my-handle.bsky.social') // Result from cache
```

### Observing resolution errors

By default, `WellKnownHandleResolver` (used internally by `AtprotoHandleResolver`
and `AtprotoHandleResolverNode`) returns `null` for non-abort errors,
indistinguishable from "no `.well-known/atproto-did` endpoint exists". That is
correct for the resolver contract, but it hides operational signals such as 5xx
upstream errors, SSRF blocks, redirect loops, or transient network failures.

To log or surface those failures, pass an `onError` handler when constructing the
resolver:

```ts
const resolver = new AtprotoHandleResolverNode({
  onError: (err, { resolver, handle }) => {
    console.warn(
      `[handle-resolver:${resolver}] ${handle} failed to resolve:`,
      err,
    )
  },
})
```

Because the handler is set on the instance, it also fires for resolution done on
your behalf, which a per-call option cannot reach. For example, an OAuth client
you hand the resolver to:

```ts
const client = new NodeOAuthClient({
  handleResolver: new AtprotoHandleResolverNode({ onError: logHandleError }),
  // ...
})
```

The handler is observational only: the resolver still returns `null` on failure,
so existing behavior does not change. Use it to:

- Log resolution failures for debugging or alerting.
- Surface specific failures to end users (e.g. distinguishing a misconfigured
  custom domain from a typo'd handle).
- Emit telemetry for handle-host operators.

---

### Overview (`packages/internal/opentelemetry-node/README.md`)

# @atproto-labs/opentelemetry-node

OpenTelemetry bootstrap for atproto Node.js services.

This package wires up the OpenTelemetry Node SDK from the conventional `OTEL_*`
environment variables, so a service only has to supply its identity and the list
of instrumentations it wants.

Telemetry is **opt-in via the exporter endpoint**: the SDK stays disabled until an
OTLP endpoint is configured (`OTEL_EXPORTER_OTLP_ENDPOINT` or a signal-specific
variant). `OTEL_SDK_DISABLED` remains a kill switch.

## Configuration

Everything beyond the service identity and instrumentation list is driven by the
conventional `OTEL_*` environment variables. See the OpenTelemetry docs for the
full set:

- [General SDK configuration](https://opentelemetry.io/docs/languages/sdk-configuration/general/)
- [OTLP exporter configuration](https://opentelemetry.io/docs/languages/sdk-configuration/otlp-exporter/)

## Usage

Load it before any instrumented module — typically through Node's `--import` flag:

```ts
import { setup } from '@atproto-labs/opentelemetry-node'
import { ATTR_DEPLOYMENT_ENVIRONMENT_NAME } from '@atproto-labs/opentelemetry-node/conventions'
import { IORedisInstrumentation } from '@opentelemetry/instrumentation-ioredis'
import pkg from './package.json' with { type: 'json' }

setup(() => ({
  name: pkg.name,
  version: pkg.version,
  // Optional: override or extend the default resource attributes.
  defaultResourceAttributes: { [ATTR_DEPLOYMENT_ENVIRONMENT_NAME]: 'staging' },
  // Service-specific instrumentations. The instrumentations common to atproto
  // services are always registered in addition to these.
  instrumentations: [new IORedisInstrumentation()],
}))
```

`setup()` takes a thunk so nothing is constructed when telemetry is disabled: the
options — and therefore the instrumentations — are only built once an OTLP endpoint
is configured.

### XRPC routes on metrics

Pass the known XRPC methods the service may answer (including the ones it
proxies) as `xrpcMethods`. This allows to set the `http.route` attribute
correctly for these methods in the metrics. When `xrpcMethods` is omitted, the
`http.route` of the metrics is left as set by the Express instrumentation.

```ts
// @NOTE **do not** import runtime code here (like `ids` from
// `@atproto/lex-cli`'s `./lexicon/lexicons.js`) as telemetry setup must run
// before any instrumented module. Importing from the `@atproto/lex` manifest
// (JSON) is fine.

import { setup } from '@atproto-labs/opentelemetry-node'
import pkg from './package.json' with { type: 'json' }
import lex from './lexicons.json' with { type: 'json' }

setup(() => ({
  name: pkg.name,
  version: pkg.version,
  xrpcMethods: lex.lexicons
    // @NOTE we only care about methods, which, by convention, have a verb
    // followed by a capitalized noun (e.g., `getFeed`). Reducing the list
    // helps keep the attribute low-cardinality.
    .filter((v) => /[A-Z]/.test(v)),
}))
```

This sets the `http.route` attribute of the HTTP server duration metric to
`/xrpc/<nsid>` for these methods, and to `/xrpc/{unknown}` for any other NSID.
The list is what keeps the attribute low-cardinality: any client
can make up an NSID. Span names aren't affected.

The instrumentations common to atproto services (HTTP with XRPC-aware span naming,
Express, Undici, Pino with log correlation, and Node runtime metrics) are always
registered alongside the ones you supply. The
`@atproto-labs/opentelemetry-node/instrumentation` entrypoint exposes
`getDefaultAtprotoInstrumentations()` if you need that list directly.

The `@atproto-labs/opentelemetry-node/conventions` entrypoint re-exports
`@opentelemetry/semantic-conventions` plus the atproto-specific constants: the
XRPC attribute keys (`ATTR_XRPC_METHOD`, `ATTR_XRPC_PROXIED`,
`ATTR_XRPC_PROXY`), the bsync stash attribute keys (`ATTR_BSYNC_NAMESPACE`,
`ATTR_BSYNC_OPERATION`), and `RPC_CALL_DURATION_BUCKETS`. The
`@atproto-labs/opentelemetry-node/util` entrypoint exposes
`statusCodeToString()`, which renders a Connect status as the snake_case string
`rpc.response.status_code` expects.

Both sides of an RPC need to agree on these, so they live here rather than in
either service — otherwise the caller and the callee end up depending on each
other just to label a metric.

## License

This project is dual-licensed under MIT and Apache 2.0 terms:

- MIT license ([LICENSE-MIT.txt](https://github.com/bluesky-social/atproto/blob/main/LICENSE-MIT.txt) or http://opensource.org/licenses/MIT)
- Apache License, Version 2.0, ([LICENSE-APACHE.txt](https://github.com/bluesky-social/atproto/blob/main/LICENSE-APACHE.txt) or http://www.apache.org/licenses/LICENSE-2.0)

Downstream projects and end users may chose either license individually, or both together, at their discretion. The motivation for this dual-licensing is the additional software patent assurance provided by Apache 2.0.

Bluesky Social PBC has committed to a software patent non-aggression pledge. For details see [the original announcement](https://bsky.social/about/blog/10-01-2025-patent-pledge).

---

### Overview (`packages/internal/rolldown-plugin-bundle-manifest/README.md`)

# @atproto-labs/rolldown-plugin-bundle-manifest

This Rolldown plugin allows to generate a (JSON) manifest containing the output
files of a Rolldown build. The manifest will look as follows:

```json
{
  "main.js": {
    "type": "chunk",
    "mime": "application/javascript",
    "dynamicImports": [],
    "isDynamicEntry": false,
    "isEntry": true,
    "name": "main",
    "sha256": "<sha256-hash>",
    "data": "<base64-encoded-contents>"
  },
  "main.js.map": {
    "type": "asset",
    "mime": "application/json",
    "sha256": "<sha256-hash>",
    "data": "<base64-encoded-contents>"
  },
  "main.css": {
    "type": "asset",
    "mime": "text/css",
    "sha256": "<sha256-hash>",
    "data": "<base64-encoded-contents>"
  }
  // ... more entries as needed
}
```

This manifest will typically be useful for a backend service that serves the
frontend assets, as it can be used to determine the correct `Content-Type` and
and file integrity (via the SHA-256 hash), without having to read the files
themselves.

## Usage

```js
// rolldown.config.js

import { bundleManifest } from '@atproto-labs/rolldown-plugin-bundle-manifest'

export default {
  input: 'src/index.js',
  output: {
    dir: 'dist',
    format: 'es',
  },
  plugins: [
    bundleManifest({
      name: 'bundle-manifest.json',

      // Optional: should the asset data be embedded (as base64 string) in the manifest?
      data: false,
    }),
  ],
}
```

## Options

- `name` (string): The name of the manifest file. Defaults to `bundle-manifest.json`.
- `data` (boolean): Whether to embed the asset data in the manifest. Defaults to `false`.

## Example

```js
const assetManifest = require('./dist/bundle-manifest.json')

const app = express()

app.use((req, res, next) => {
  const asset = assetManifest[req.path.slice(1)]
  if (!asset) return next()

  res.setHeader('Content-Type', asset.mime)
  res.setHeader('Content-Length', asset.data.length)

  res.end(Buffer.from(asset.data, 'base64'))
})

app.use((req, res, next) => {
  res.setHeader(
    'Content-Security-Policy',
    buildCSP(assetManifest), // Not provided here
  )

  // Serve the index.html file
  res.sendFile('index.html')
})
```

## License

MIT

---

### Overview (`packages/lex/lex-builder/README.md`)

# @atproto/lex-builder

- [Changelog](./CHANGELOG.md)
- [`@atproto/lex` documentation](https://github.com/bluesky-social/atproto/blob/main/packages/lex/lex/README.md)

Generates TypeScript schemas from [Lexicon](https://atproto.com/specs/lexicon) documents. This is the engine behind the `lex build` command.

```typescript
import { build } from '@atproto/lex-builder'

await build({ lexicons: './lexicons', out: './src/lexicons' })
```

Most projects should use the `lex build` CLI from [`@atproto/lex`](https://github.com/bluesky-social/atproto/tree/main/packages/lex/lex) rather than calling this package directly.

## License

This project is dual-licensed under MIT and Apache 2.0 terms:

- MIT license ([LICENSE-MIT.txt](https://github.com/bluesky-social/atproto/blob/main/LICENSE-MIT.txt) or http://opensource.org/licenses/MIT)
- Apache License, Version 2.0, ([LICENSE-APACHE.txt](https://github.com/bluesky-social/atproto/blob/main/LICENSE-APACHE.txt) or http://www.apache.org/licenses/LICENSE-2.0)

Downstream projects and end users may chose either license individually, or both together, at their discretion. The motivation for this dual-licensing is the additional software patent assurance provided by Apache 2.0.

---

### Overview (`packages/lex/lex-cbor/README.md`)

# @atproto/lex-cbor

- [Changelog](./CHANGELOG.md)
- [`@atproto/lex` documentation](https://github.com/bluesky-social/atproto/blob/main/packages/lex/lex/README.md)

CBOR ([DRISL](https://dasl.ing/drisl.html)) encoding and decoding for the AT Protocol [data model](https://atproto.com/specs/data-model), as used for [repository](https://atproto.com/specs/repository) storage and signing.

```typescript
import { decode, encode, cidForLex } from '@atproto/lex-cbor'

const bytes = encode(someLexValue)
const value = decode(bytes)
const cid = await cidForLex(someLexValue)
```

Part of the [`@atproto/lex`](https://github.com/bluesky-social/atproto/tree/main/packages/lex/lex) SDK family. Unlike the JSON codec, this package is _not_ re-exported by `@atproto/lex` and must be installed directly.

## License

This project is dual-licensed under MIT and Apache 2.0 terms:

- MIT license ([LICENSE-MIT.txt](https://github.com/bluesky-social/atproto/blob/main/LICENSE-MIT.txt) or http://opensource.org/licenses/MIT)
- Apache License, Version 2.0, ([LICENSE-APACHE.txt](https://github.com/bluesky-social/atproto/blob/main/LICENSE-APACHE.txt) or http://www.apache.org/licenses/LICENSE-2.0)

Downstream projects and end users may chose either license individually, or both together, at their discretion. The motivation for this dual-licensing is the additional software patent assurance provided by Apache 2.0.

---

### Overview (`packages/lex/lex-client/README.md`)

# @atproto/lex-client

- [Changelog](./CHANGELOG.md)
- [`@atproto/lex` documentation](https://github.com/bluesky-social/atproto/blob/main/packages/lex/lex/README.md)
- [`@atproto/lex-rpc` low-level XRPC utilities](https://github.com/bluesky-social/atproto/blob/main/packages/lex/lex-rpc/README.md)

Type-safe HTTP ([XRPC](https://atproto.com/specs/xrpc)) client for Lexicon-defined APIs: the `Client` class for working with a user's PDS (records) in the context of an authenticated session.

Authentication is supplied through an `Agent`, implemented by [`@atproto/oauth-client`](https://github.com/bluesky-social/atproto/tree/main/packages/oauth/oauth-client) or, for scripts and bots, [`@atproto/lex-password-session`](https://github.com/bluesky-social/atproto/tree/main/packages/lex/lex-password-session).

This is a building block of [`@atproto/lex`](https://github.com/bluesky-social/atproto/tree/main/packages/lex/lex), which re-exports it -- prefer depending on that package.

## License

This project is dual-licensed under MIT and Apache 2.0 terms:

- MIT license ([LICENSE-MIT.txt](https://github.com/bluesky-social/atproto/blob/main/LICENSE-MIT.txt) or http://opensource.org/licenses/MIT)
- Apache License, Version 2.0, ([LICENSE-APACHE.txt](https://github.com/bluesky-social/atproto/blob/main/LICENSE-APACHE.txt) or http://www.apache.org/licenses/LICENSE-2.0)

Downstream projects and end users may chose either license individually, or both together, at their discretion. The motivation for this dual-licensing is the additional software patent assurance provided by Apache 2.0.

---

### Overview (`packages/lex/lex-data/README.md`)

# @atproto/lex-data

- [Changelog](./CHANGELOG.md)
- [`@atproto/lex` documentation](https://github.com/bluesky-social/atproto/blob/main/packages/lex/lex/README.md)

Core types and utilities for the AT Protocol [data model](https://atproto.com/specs/data-model): `LexValue` and friends, CIDs, bytes, [blob](https://atproto.com/specs/blob) references, and string-length helpers.

The data model extends JSON with two additional structures — CIDs (content-addressed links) and raw bytes — which are encoded as JSON by [`@atproto/lex-json`](https://github.com/bluesky-social/atproto/tree/main/packages/lex/lex-json) and as CBOR by [`@atproto/lex-cbor`](https://github.com/bluesky-social/atproto/tree/main/packages/lex/lex-cbor).

This is a building block of [`@atproto/lex`](https://github.com/bluesky-social/atproto/tree/main/packages/lex/lex), which re-exports it — prefer depending on that package.

## License

This project is dual-licensed under MIT and Apache 2.0 terms:

- MIT license ([LICENSE-MIT.txt](https://github.com/bluesky-social/atproto/blob/main/LICENSE-MIT.txt) or http://opensource.org/licenses/MIT)
- Apache License, Version 2.0, ([LICENSE-APACHE.txt](https://github.com/bluesky-social/atproto/blob/main/LICENSE-APACHE.txt) or http://www.apache.org/licenses/LICENSE-2.0)

Downstream projects and end users may chose either license individually, or both together, at their discretion. The motivation for this dual-licensing is the additional software patent assurance provided by Apache 2.0.

---

### Overview (`packages/lex/lex-document/README.md`)

# @atproto/lex-document

- [Changelog](./CHANGELOG.md)
- [`@atproto/lex` documentation](https://github.com/bluesky-social/atproto/blob/main/packages/lex/lex/README.md)

Validation of Lexicon documents themselves — the JSON files written in the [Lexicon schema definition language](https://atproto.com/specs/lexicon).

Where [`@atproto/lex-schema`](https://github.com/bluesky-social/atproto/tree/main/packages/lex/lex-schema) validates _data_ against a schema, this package validates the _schema documents_, and turns them into runtime schemas by resolving refs across documents.

Part of the [`@atproto/lex`](https://github.com/bluesky-social/atproto/tree/main/packages/lex/lex) SDK family, used by [`lex-builder`](https://github.com/bluesky-social/atproto/tree/main/packages/lex/lex-builder), [`lex-installer`](https://github.com/bluesky-social/atproto/tree/main/packages/lex/lex-installer) and [`lex-resolver`](https://github.com/bluesky-social/atproto/tree/main/packages/lex/lex-resolver).

## License

This project is dual-licensed under MIT and Apache 2.0 terms:

- MIT license ([LICENSE-MIT.txt](https://github.com/bluesky-social/atproto/blob/main/LICENSE-MIT.txt) or http://opensource.org/licenses/MIT)
- Apache License, Version 2.0, ([LICENSE-APACHE.txt](https://github.com/bluesky-social/atproto/blob/main/LICENSE-APACHE.txt) or http://www.apache.org/licenses/LICENSE-2.0)

Downstream projects and end users may chose either license individually, or both together, at their discretion. The motivation for this dual-licensing is the additional software patent assurance provided by Apache 2.0.

---

### Overview (`packages/lex/lex-installer/README.md`)

# @atproto/lex-installer

- [Changelog](./CHANGELOG.md)
- [`@atproto/lex` documentation](https://github.com/bluesky-social/atproto/blob/main/packages/lex/lex/README.md)

Package manager for [Lexicon](https://atproto.com/specs/lexicon) documents. This is the engine behind the `lex install` command: it resolves Lexicons by [NSID](https://atproto.com/specs/nsid), writes them to a local directory, and tracks their versions (CIDs) in a `lexicons.json` manifest.

```typescript
import { install } from '@atproto/lex-installer'

await install({
  lexicons: './lexicons',
  manifest: './lexicons.json',
  add: ['app.bsky.feed.post'],
  save: true,
})
```

Most projects should use the `lex install` CLI from [`@atproto/lex`](https://github.com/bluesky-social/atproto/tree/main/packages/lex/lex) rather than calling this package directly.

## Resolution sources

Each Lexicon is resolved from, in order of priority:

1. **An explicit `at://` URI** passed as an addition — fetched (and verified) from that repo.
2. **An explicit local file** passed as an addition — any value that is a `file://` URI or
   contains a slash (`./app.bsky.feed.post.json`, `file://../shared/my.json`) is read from disk
   (relative to the cwd); its NSID is taken from the document's `id`.
3. **The manifest's `resolvers`** — an ordered list of local override strategies consulted for
   every dependency NSID before the network. Currently only `directory` is supported:

   ```jsonc
   {
     "version": 1,
     "lexicons": ["com.example.foo"],
     "resolvers": [
       {
         "type": "directory",
         "path": "../../lexicons", // relative to this lexicons.json
         "include": ["com.example.*"], // optional NSID globs (default: all)
         "exclude": [], // optional NSID globs this resolver never answers for
       },
     ],
     "resolutions": {
       "com.example.foo": {
         "uri": "file://../../lexicons/com/example/foo.json",
         "cid": "...",
       },
     },
   }
   ```

   Resolvers are priority-ordered (first match wins) and are **not** reordered by manifest
   normalization. A `directory` resolver maps an NSID to `<path>/<a>/<b>/<c>.json`.

4. **The network** — the always-last fallback: NSID → DNS `_lexicon.<authority>` → DID → signed
   `com.atproto.lexicon.schema` record.

> [!NOTE]
> A future `{ "type": "repo", ... }` resolver (resolving from specific DIDs rather than DNS
> discovery) is planned but not yet implemented.

### Symlinks

Lexicons resolved from a **local file** (sources 2 and 3 above) are **symlinked** into the output
`lexicons/` directory rather than copied, so edits to the source file are reflected without
reinstalling. The symlink is written relative to its destination for portability. As an exception,
if the resolved destination path equals the source path, the file is left untouched. Their manifest
lock `uri` is a `file://` path relative to the manifest, keeping it portable across checkouts.
Network-resolved Lexicons are written as plain JSON files and locked with their `at://` URI.

## License

This project is dual-licensed under MIT and Apache 2.0 terms:

- MIT license ([LICENSE-MIT.txt](https://github.com/bluesky-social/atproto/blob/main/LICENSE-MIT.txt) or http://opensource.org/licenses/MIT)
- Apache License, Version 2.0, ([LICENSE-APACHE.txt](https://github.com/bluesky-social/atproto/blob/main/LICENSE-APACHE.txt) or http://www.apache.org/licenses/LICENSE-2.0)

Downstream projects and end users may chose either license individually, or both together, at their discretion. The motivation for this dual-licensing is the additional software patent assurance provided by Apache 2.0.

---

### Overview (`packages/lex/lex-json/README.md`)

# @atproto/lex-json

- [Changelog](./CHANGELOG.md)
- [`@atproto/lex` documentation](https://github.com/bluesky-social/atproto/blob/main/packages/lex/lex/README.md)

JSON encoding and decoding for the AT Protocol [data model](https://atproto.com/specs/data-model), including the `$link` (CID) and `$bytes` representations used by [XRPC](https://atproto.com/specs/xrpc).

Provides `lexParse` / `lexStringify` to go between JSON strings and data-model values, and `jsonToLex` / `lexToJson` to convert already-parsed JSON objects. For the CBOR codec used by repository storage, see [`@atproto/lex-cbor`](https://github.com/bluesky-social/atproto/tree/main/packages/lex/lex-cbor).

This is a building block of [`@atproto/lex`](https://github.com/bluesky-social/atproto/tree/main/packages/lex/lex), which re-exports it — prefer depending on that package.

## License

This project is dual-licensed under MIT and Apache 2.0 terms:

- MIT license ([LICENSE-MIT.txt](https://github.com/bluesky-social/atproto/blob/main/LICENSE-MIT.txt) or http://opensource.org/licenses/MIT)
- Apache License, Version 2.0, ([LICENSE-APACHE.txt](https://github.com/bluesky-social/atproto/blob/main/LICENSE-APACHE.txt) or http://www.apache.org/licenses/LICENSE-2.0)

Downstream projects and end users may chose either license individually, or both together, at their discretion. The motivation for this dual-licensing is the additional software patent assurance provided by Apache 2.0.

---

### Overview (`packages/lex/lex-password-session/README.md`)

# @atproto/lex-password-session

Password-based session authentication for AT Protocol Lexicons. See the [Changelog](./CHANGELOG.md) for version history.

```bash
npm install @atproto/lex-password-session
```

- Session management with automatic token refresh
- Hooks for persisting and monitoring session state
- PDS endpoint discovery from DID documents
- Two-factor authentication support

> [!IMPORTANT]
>
> This package is currently in **preview**. The API and features are subject to change before the stable release.

**What is this?**

`@atproto/lex-password-session` provides a `PasswordSession` class that implements the `Agent` interface from `@atproto/lex-client`. It handles password-based authentication with AT Protocol services, including:

1. Creating sessions with username/password credentials
2. Automatic token refresh when access tokens expire
3. Session persistence through lifecycle hooks
4. Graceful logout with server-side session cleanup

```typescript
import { Client } from '@atproto/lex-client'
import { PasswordSession } from '@atproto/lex-password-session'
import * as app from './lexicons/app.js'

// Login with credentials
const session = await PasswordSession.login({
  service: 'https://bsky.social',
  identifier: 'alice.bsky.social',
  password: 'app-password',
  onUpdated: (data) => saveToStorage(data),
  onDeleted: (data) => clearStorage(data.did),
})

const client = new Client(session)

// Make authenticated requests
const profile = await client.call(app.bsky.actor.getProfile, {
  actor: session.did,
})
```




- [Quick Start](#quick-start)
- [PasswordSession](#passwordsession)
  - [Login](#login)
  - [Two-Factor Authentication](#two-factor-authentication)
  - [Resume Session](#resume-session)
  - [Logout](#logout)
  - [Static Delete](#static-delete)
  - [Create Account](#create-account)
- [Session Hooks](#session-hooks)
  - [onUpdated](#onupdated)
  - [onUpdateFailure](#onupdatefailure)
  - [onDeleted](#ondeleted)
  - [onDeleteFailure](#ondeletefailure)
- [Session Data](#session-data)
- [Error Handling](#error-handling)
- [Using with Client](#using-with-client)
- [License](#license)



## Quick Start

**1. Install the package**

```bash
npm install @atproto/lex-password-session @atproto/lex-client
```

**2. Login and make requests**

```typescript
import { Client } from '@atproto/lex-client'
import { PasswordSession } from '@atproto/lex-password-session'

const session = await PasswordSession.login({
  service: 'https://bsky.social',
  identifier: 'your-handle.bsky.social',
  password: 'your-app-password',
})

const client = new Client(session)

// Make authenticated API calls
console.log('Logged in as:', session.did)
```

## PasswordSession

The `PasswordSession` class manages password-based authentication sessions.

### Login

Create a new session with username and password:

```typescript
import { PasswordSession } from '@atproto/lex-password-session'

const session = await PasswordSession.login({
  service: 'https://bsky.social',
  identifier: 'alice.bsky.social', // handle or email
  password: 'app-password',
  onUpdated: (data) => {
    // Persist session for later restoration
    localStorage.setItem('session', JSON.stringify(data))
  },
  onDeleted: () => {
    localStorage.removeItem('session')
  },
})

console.log('Logged in as:', session.did)
```

The `login()` method throws on failure. For expected errors like invalid credentials, an `XrpcResponseError` is thrown. For 2FA requirements, a `LexAuthFactorError` is thrown.

### Two-Factor Authentication

> [!CAUTION]
>
> Two-factor authentication only applies when using **main account credentials**, which is **strongly discouraged**. Password authentication should be used with [app passwords](https://bsky.app/settings/app-passwords) only because they are designed for programmatic access (bots, scripts, CLI tools). For user-facing applications, use OAuth via [@atproto/oauth-client](../../../oauth/oauth-client) which provides better security and user control.

If the account has 2FA enabled, login will throw a `LexAuthFactorError`:

```typescript
import {
  PasswordSession,
  LexAuthFactorError,
} from '@atproto/lex-password-session'

async function loginWith2FA(
  identifier: string,
  password: string,
  authFactorToken?: string,
): Promise<PasswordSession> {
  try {
    return await PasswordSession.login({
      service: 'https://bsky.social',
      identifier,
      password,
      authFactorToken,
      onUpdated: (data) => saveToStorage(data),
      onDeleted: (data) => removeFromStorage(data.did),
    })
  } catch (err) {
    if (err instanceof LexAuthFactorError && !authFactorToken) {
      // 2FA required - prompt user for code
      const token = await promptUserFor2FACode(err.message)
      return loginWith2FA(identifier, password, token)
    }
    throw err
  }
}
```

### Resume Session

Restore a previously saved session:

```typescript
import { PasswordSession, SessionData } from '@atproto/lex-password-session'

// Load session from storage
const savedSession: SessionData = JSON.parse(localStorage.getItem('session')!)

// Resume the session (automatically refreshes tokens)
const session = await PasswordSession.resume(savedSession, {
  onUpdated: (data) => {
    localStorage.setItem('session', JSON.stringify(data))
  },
  onDeleted: () => {
    localStorage.removeItem('session')
  },
})

console.log('Session resumed for:', session.did)

// Access session properties
console.log(session.did) // User's DID
console.log(session.handle) // User's handle
console.log(session.destroyed) // false (session is active)
```

> [!NOTE]
>
> `resume()` automatically calls `refresh()` to ensure the session is valid and tokens are current.

### Logout

End the session and notify the server:

```typescript
await session.logout()
```

After logout:

- The `onDeleted` hook is called
- The session is marked as destroyed (`session.destroyed === true`)
- Further requests will throw `'Logged out'`

### Static Delete

Delete a session without creating a session instance:

```typescript
import { PasswordSession, SessionData } from '@atproto/lex-password-session'

const data: SessionData = JSON.parse(localStorage.getItem('session')!)

// Delete the session on the server
await PasswordSession.delete(data)
```

This is useful for cleanup scenarios where you don't need to make additional requests.

### Create Account

Create a new account and get an authenticated session:

```typescript
import { PasswordSession } from '@atproto/lex-password-session'

const session = await PasswordSession.createAccount(
  {
    handle: 'alice.bsky.social',
    email: 'alice@example.com',
    password: 'secure-password',
  },
  {
    service: 'https://bsky.social',
    onUpdated: (data) => saveToStorage(data),
    onDeleted: (data) => removeFromStorage(data.did),
  },
)

console.log('Account created:', session.did)
```

## Session Hooks

Hooks provide callbacks for session lifecycle events. All hooks receive the session instance as `this` context.

### onUpdated

Called when the session is successfully created or refreshed:

```typescript
const session = await PasswordSession.login({
  service: 'https://bsky.social',
  identifier: 'alice.bsky.social',
  password: 'app-password',
  onUpdated(data) {
    // `this` is the PasswordSession instance
    console.log('Session updated for:', this.did)

    // Persist the updated session
    saveSession(data)
  },
})
```

> [!IMPORTANT]
>
> Requests are blocked while `onUpdated` is running. Keep this callback fast to avoid delays.

### onUpdateFailure

Called when token refresh fails due to transient errors (network issues, server unavailability):

```typescript
{
  onUpdateFailure(data, error) {
    console.warn('Token refresh failed:', error.message)
    // Session may still be valid - consider retry logic
  }
}
```

### onDeleted

Called when the session is terminated (logout or server-side invalidation):

```typescript
{
  onDeleted(data) {
    console.log('Session ended for:', data.did)
    clearPersistedSession(data.did)
    redirectToLogin()
  }
}
```

### onDeleteFailure

Called when logout fails due to transient errors:

```typescript
{
  onDeleteFailure(data, error) {
    console.error('Logout failed:', error.message)
    // Consider queuing for retry to avoid orphaned sessions
    queueLogoutRetry(data)
  }
}
```

> [!WARNING]
>
> Ignoring delete failures can leave sessions active on the server. Implement retry logic for security-sensitive applications.

## Session Data

The `SessionData` type contains all data needed to authenticate and restore sessions:

```typescript
type SessionData = {
  // Session credentials and user info from createSession response
  accessJwt: string
  refreshJwt: string
  did: string
  handle: string
  email?: string
  emailConfirmed?: boolean
  didDoc?: object
  // ... other fields from createSession

  // Original service URL used for login
  service: string
}
```

## Error Handling

The `PasswordSession` class uses exception-based error handling:

```typescript
import {
  PasswordSession,
  LexAuthFactorError,
} from '@atproto/lex-password-session'
import { XrpcResponseError } from '@atproto/lex-client'

try {
  const session = await PasswordSession.login({
    service: 'https://bsky.social',
    identifier: 'alice.bsky.social',
    password: 'wrong-password',
  })
} catch (err) {
  if (err instanceof LexAuthFactorError) {
    console.error('2FA required')
  } else if (err instanceof XrpcResponseError) {
    switch (err.error) {
      case 'AuthenticationRequired':
        console.error('Invalid credentials')
        break
      case 'AccountTakedown':
        console.error('Account has been suspended')
        break
      default:
        console.error('Login failed:', err.message)
    }
  } else {
    throw err
  }
}
```

Common error codes:

| Error Code                | Description                    |
| ------------------------- | ------------------------------ |
| `AuthenticationRequired`  | Invalid username or password   |
| `AuthFactorTokenRequired` | 2FA code needed                |
| `AccountTakedown`         | Account suspended              |
| `ExpiredToken`            | Token has expired (on refresh) |
| `InvalidToken`            | Token is invalid               |

## Using with Client

The `PasswordSession` implements the `Agent` interface and can be used directly with `Client`:

```typescript
import { Client } from '@atproto/lex-client'
import { PasswordSession } from '@atproto/lex-password-session'
import * as app from './lexicons/app.js'

const session = await PasswordSession.login({
  service: 'https://bsky.social',
  identifier: 'alice.bsky.social',
  password: 'app-password',
})

const client = new Client(session)

// The client automatically uses the session for authentication
const profile = await client.call(app.bsky.actor.getProfile, {
  actor: client.assertDid,
})

// Tokens are automatically refreshed when expired
const timeline = await client.call(app.bsky.feed.getTimeline, {
  limit: 50,
})

// Create records
await client.create(app.bsky.feed.post, {
  text: 'Hello from lex-password-session!',
  createdAt: new Date().toISOString(),
})
```

The session handles:

- Adding `Authorization` headers to requests
- Detecting expired tokens (401 responses or `ExpiredToken` errors)
- Automatically refreshing tokens and retrying failed requests
- Routing requests to the correct PDS based on DID document

## License

MIT or Apache2

---

### Overview (`packages/lex/lex-resolver/README.md`)

# @atproto/lex-resolver

- [Changelog](./CHANGELOG.md)
- [`@atproto/lex` documentation](https://github.com/bluesky-social/atproto/blob/main/packages/lex/lex/README.md)

Resolves [Lexicon](https://atproto.com/specs/lexicon) documents from the AT Protocol network by [NSID](https://atproto.com/specs/nsid).

```typescript
import { LexResolver } from '@atproto/lex-resolver'

const resolver = new LexResolver({})
const { lexicon, uri, cid } = await resolver.get('app.bsky.feed.post')
```

Resolution looks up the `_lexicon.<authority>` DNS TXT record to find the controlling [DID](https://atproto.com/specs/did), resolves that DID to a PDS, then fetches and cryptographically verifies the Lexicon record. Hooks allow caching at the fetch layer.

Part of the [`@atproto/lex`](https://github.com/bluesky-social/atproto/tree/main/packages/lex/lex) SDK family, used by [`@atproto/lex-installer`](https://github.com/bluesky-social/atproto/tree/main/packages/lex/lex-installer) to implement `lex install`.

## License

This project is dual-licensed under MIT and Apache 2.0 terms:

- MIT license ([LICENSE-MIT.txt](https://github.com/bluesky-social/atproto/blob/main/LICENSE-MIT.txt) or http://opensource.org/licenses/MIT)
- Apache License, Version 2.0, ([LICENSE-APACHE.txt](https://github.com/bluesky-social/atproto/blob/main/LICENSE-APACHE.txt) or http://www.apache.org/licenses/LICENSE-2.0)

Downstream projects and end users may chose either license individually, or both together, at their discretion. The motivation for this dual-licensing is the additional software patent assurance provided by Apache 2.0.

---

### Overview (`packages/lex/lex-rpc/README.md`)

# @atproto/lex-rpc

- [Changelog](./CHANGELOG.md)
- [`@atproto/lex` documentation](https://github.com/bluesky-social/atproto/blob/main/packages/lex/lex/README.md)

Type-safe HTTP ([XRPC](https://atproto.com/specs/xrpc)) utilities for Lexicon-defined APIs: exposes low-level `xrpc()` / `xrpcSafe()` functions for making requests based on Lexicon derived schemas.

## License

This project is dual-licensed under MIT and Apache 2.0 terms:

- MIT license ([LICENSE-MIT.txt](https://github.com/bluesky-social/atproto/blob/main/LICENSE-MIT.txt) or http://opensource.org/licenses/MIT)
- Apache License, Version 2.0, ([LICENSE-APACHE.txt](https://github.com/bluesky-social/atproto/blob/main/LICENSE-APACHE.txt) or http://www.apache.org/licenses/LICENSE-2.0)

Downstream projects and end users may chose either license individually, or both together, at their discretion. The motivation for this dual-licensing is the additional software patent assurance provided by Apache 2.0.

---

### Overview (`packages/lex/lex-schema/README.md`)

# @atproto/lex-schema

- [Changelog](./CHANGELOG.md)
- [`@atproto/lex` documentation](https://github.com/bluesky-social/atproto/blob/main/packages/lex/lex/README.md)

Runtime schema system for AT Protocol [Lexicons](https://atproto.com/specs/lexicon). This is what the TypeScript code generated by `lex build` is built on, and it provides the `l` namespace for declaring schemas by hand.

Schemas expose validation helpers (`$parse`, `$validate`, `$matches`, `$assert`, …) and implement the [Standard Schema](https://standardschema.dev/) interface.

This is a building block of [`@atproto/lex`](https://github.com/bluesky-social/atproto/tree/main/packages/lex/lex), which re-exports it — prefer depending on that package.

## License

This project is dual-licensed under MIT and Apache 2.0 terms:

- MIT license ([LICENSE-MIT.txt](https://github.com/bluesky-social/atproto/blob/main/LICENSE-MIT.txt) or http://opensource.org/licenses/MIT)
- Apache License, Version 2.0, ([LICENSE-APACHE.txt](https://github.com/bluesky-social/atproto/blob/main/LICENSE-APACHE.txt) or http://www.apache.org/licenses/LICENSE-2.0)

Downstream projects and end users may chose either license individually, or both together, at their discretion. The motivation for this dual-licensing is the additional software patent assurance provided by Apache 2.0.

---

### Overview (`packages/lex/lex-server/README.md`)

# @atproto/lex-server

Request router for Atproto Lexicon protocols and schemas. See the [Changelog](./CHANGELOG.md) for version history.

```bash
npm install @atproto/lex-server
```

- Type-safe request routing based on Lexicon schemas
- Support for queries, procedures, and WebSocket subscriptions
- Built on Web standard `Request`/`Response` APIs (portable across runtimes)
- Custom authentication with credential passing
- Graceful shutdown with `AsyncDisposable` pattern

> [!IMPORTANT]
>
> This package is currently in **preview**. The API and features are subject to change before the stable release.

**What is this?**

Building AT Protocol servers requires handling XRPC requests, validating inputs against Lexicon schemas, managing authentication, and supporting real-time subscriptions. `@atproto/lex-server` automates this by:

1. Routing requests to type-safe handlers based on Lexicon schemas
2. Automatically validating request parameters and bodies
3. Providing a flexible authentication system with custom strategies
4. Supporting WebSocket subscriptions with backpressure handling

```typescript
import { LexRouter } from '@atproto/lex-server'
import { serve, upgradeWebSocket } from '@atproto/lex-server/nodejs'
import * as app from './lexicons/app.js'

const router = new LexRouter({ upgradeWebSocket })
  .add(app.bsky.actor.getProfile, async ({ params }) => {
    const profile = await db.getProfile(params.actor)
    return { body: profile }
  })
  .add(app.bsky.feed.post.create, {
    auth: requireAuth,
    handler: async ({ credentials, input }) => {
      const result = await db.createPost(credentials.did, input.body)
      return { body: result }
    },
  })

await serve(router, { port: 3000 })
```




- [Quick Start](#quick-start)
- [LexRouter](#lexrouter)
  - [Creating a Router](#creating-a-router)
  - [Adding Routes](#adding-routes)
  - [Handler Context](#handler-context)
  - [Handler Output](#handler-output)
- [Queries and Procedures](#queries-and-procedures)
  - [Query Handler](#query-handler)
  - [Procedure Handler](#procedure-handler)
  - [Binary Payloads](#binary-payloads)
- [Subscriptions](#subscriptions)
- [Authentication](#authentication)
  - [Custom Authentication](#custom-authentication)
  - [WWW-Authenticate Headers](#www-authenticate-headers)
- [Error Handling](#error-handling)
  - [LexServerError](#lexservererror)
  - [LexServerAuthError](#lexserverautherror)
  - [Error Handler Callback](#error-handler-callback)
- [Node.js Server](#nodejs-server)
  - [serve()](#serve)
  - [createServer()](#createserver)
  - [toRequestListener()](#torequestlistener)
  - [upgradeWebSocket()](#upgradewebsocket)
- [Advanced Usage](#advanced-usage)
  - [Custom Response Objects](#custom-response-objects)
  - [Response Headers](#response-headers)
  - [Connection Info](#connection-info)
- [License](#license)



## Quick Start

**1. Install the package**

```bash
npm install @atproto/lex-server
```

**2. Generate Lexicon schemas**

Use `@atproto/lex` to generate TypeScript schemas from your Lexicon definitions:

```bash
lex install app.bsky.actor.getProfile
lex build
```

**3. Create a router and add handlers**

```typescript
import { LexRouter, LexServerError } from '@atproto/lex-server'
import { serve, upgradeWebSocket } from '@atproto/lex-server/nodejs'
import * as app from './lexicons/app.js'

const router = new LexRouter({ upgradeWebSocket })

// Add a query handler
router.add(app.bsky.actor.getProfile, async ({ params }) => {
  const profile = await db.getProfile(params.actor)
  if (!profile) {
    throw new LexServerError(404, {
      error: 'NotFound',
      message: 'Profile not found',
    })
  }
  return { body: profile }
})

// Start the server
const server = await serve(router, { port: 3000 })
console.log('Server listening on port 3000')
```

## LexRouter

The `LexRouter` class is the core of `@atproto/lex-server`. It routes XRPC requests to type-safe handlers based on Lexicon schemas.

### Creating a Router

```typescript
import { LexRouter } from '@atproto/lex-server'
import { upgradeWebSocket } from '@atproto/lex-server/nodejs'

const router = new LexRouter({
  // Required for WebSocket subscriptions (Node.js)
  upgradeWebSocket,

  // Optional: Add logging or error reporting for unexpected errors in handlers
  onHandlerError: ({ error, request, method }) => {
    console.error(`Error in ${method.nsid}:`, error)
  },

  // Optional: WebSocket backpressure settings
  highWaterMark: 250_000, // bytes (default: 250KB)
  lowWaterMark: 50_000, // bytes (default: 50KB)
})
```

### Adding Routes

Routes are added using the `.add()` method, which accepts a Lexicon schema and a handler:

```typescript
// Simple handler (no authentication)
router.add(schema, async ({ params, input, request }) => {
  return { body: result }
})

// Handler with authentication
router.add(schema, {
  auth: async ({ request, params }) => credentials,
  handler: async ({ params, input, credentials, request }) => {
    return { body: result }
  },
})
```

The router supports method chaining:

```typescript
const router = new LexRouter()
  .add(app.bsky.actor.getProfile, profileHandler)
  .add(app.bsky.feed.getTimeline, timelineHandler)
  .add(app.bsky.feed.post.create, postHandler)
```

### Handler Context

Handlers receive a context object with the following properties:

```typescript
type LexRouterHandlerContext<Method, Credentials> = {
  credentials: Credentials // Result of auth function (undefined if no auth)
  input: InferMethodInput<Method> // Parsed request body (procedures only)
  params: InferMethodParams<Method> // Parsed URL query parameters
  request: Request // Original Web Request object
  connection?: ConnectionInfo // Network connection info
}
```

### Handler Output

Handlers can return various output formats:

```typescript
// JSON response (encoding inferred from schema)
return { body: { key: 'value' } }

// With custom encoding
return { encoding: 'text/plain', body: 'Hello, world!' }

// With response headers
return { body: data, headers: { 'x-custom': 'value' } }

// Empty response (200 OK with no body)
return {}

// Custom Response object (full control)
return new Response(body, { status: 201, headers })

// Proxy Response
return fetch('https://example.com/data')
```

## Queries and Procedures

### Query Handler

Queries handle `GET` requests and receive parameters from the URL query string:

```typescript
import * as app from './lexicons/app.js'

router.add(app.bsky.actor.getProfile, async ({ params }) => {
  // params.actor is typed and validated
  const profile = await db.getProfile(params.actor)
  return { body: profile }
})
```

### Procedure Handler

Procedures handle `POST` requests and receive a request body:

```typescript
router.add(app.bsky.feed.post.create, async ({ input }) => {
  // input.body contains the parsed and validated request body
  const post = await db.createPost(input.body)
  return { body: { uri: post.uri, cid: post.cid } }
})
```

### Binary Payloads

For endpoints that accept or return binary data:

```typescript
// Binary input
router.add(app.example.uploadBlob, async ({ input }) => {
  // input.body is a Request object for streaming
  // input.encoding contains the content-type
  const blob = await input.body.arrayBuffer()
  return { body: { cid: await store(blob) } }
})

// Binary output
router.add(app.example.getBlob, async ({ params }) => {
  const stream = await getBlob(params.cid)
  return {
    encoding: 'application/octet-stream',
    body: stream,
  }
})
```

## Subscriptions

Subscriptions provide real-time data over WebSocket connections. Handlers are async generators that yield messages:

```typescript
import { LexRouter, LexError } from '@atproto/lex-server'
import { serve, upgradeWebSocket } from '@atproto/lex-server/nodejs'
import { scheduler } from 'node:timers/promises'

const router = new LexRouter({
  upgradeWebSocket, // Required for WebSocket support in nodejs
})

router.add(com.example.stream, async function* ({ params, request }) {
  const { cursor = 0, limit = 10 } = params
  const { signal } = request

  for (let i = 0; i < limit; i++) {
    // Yield messages to the client
    yield com.example.stream.message.$build({
      data: `Message ${cursor + i}`,
      cursor: cursor + i,
    })

    // Wait between messages (respects abort signal)
    await scheduler.wait(1000, { signal })
  }

  // Throwing a LexError closes the connection with an error frame
  throw new LexError('LimitReached', `Limit of ${limit} messages reached`)
})
```

Messages are CBOR-encoded and sent as WebSocket binary frames. The router handles:

- WebSocket upgrade negotiation
- Backpressure management
- Graceful connection cleanup
- Error frame encoding

## Authentication

### Custom Authentication

Authentication is implemented through the `auth` function in handler configs:

```typescript
import { LexError, LexServerAuthError } from '@atproto/lex-server'

type Credentials = { did: string; scope: string[] }

const requireAuth = async ({
  request,
}: {
  request: Request
}): Promise<Credentials> => {
  const header = request.headers.get('authorization')
  if (!header?.startsWith('Bearer ')) {
    throw new LexServerAuthError(
      'AuthenticationRequired',
      'Bearer token required',
      {
        Bearer: { realm: 'api' },
      },
    )
  }

  const token = header.slice(7)
  const session = await verifyToken(token)
  if (!session) {
    throw new LexServerAuthError(
      'InvalidToken',
      'Token is invalid or expired',
      {
        Bearer: { realm: 'api', error: 'invalid_token' },
      },
    )
  }

  return { did: session.did, scope: session.scope }
}

// Use with handlers
router.add(app.bsky.feed.post.create, {
  auth: requireAuth,
  handler: async ({ credentials, input }) => {
    // credentials.did is available here
    const post = await db.createPost(credentials.did, input.body)
    return { body: post }
  },
})
```

The auth function:

1. Is called **before** parsing the request body
2. Receives `params`, `request`, and `connection` info
3. Should throw `LexServerError` (or `LexServerAuthError`) on failure
4. Returns credentials that are passed to the handler

### WWW-Authenticate Headers

Use `LexServerAuthError` to include `WWW-Authenticate` headers in error responses:

```typescript
import { LexServerAuthError } from '@atproto/lex-server'

// Simple Bearer challenge
throw new LexServerAuthError('AuthenticationRequired', 'Login required', {
  Bearer: { realm: 'api' },
})
// WWW-Authenticate: Bearer realm="api"

// Multiple schemes
throw new LexServerAuthError('AuthenticationRequired', 'Auth required', {
  Bearer: { realm: 'api', scope: 'read write' },
  Basic: { realm: 'api' },
})
// WWW-Authenticate: Bearer realm="api", scope="read write", Basic realm="api"

// Token68 format
throw new LexServerAuthError('AuthenticationRequired', 'Auth required', {
  Bearer: 'token68value',
})
// WWW-Authenticate: Bearer token68value
```

## Error Handling

### LexServerError

Throw `LexServerError` to return structured XRPC error responses:

```typescript
import { LexServerError } from '@atproto/lex-server'

router.add(app.bsky.actor.getProfile, async ({ params }) => {
  try {
    const profile = await db.getProfile(params.actor)
    return { body: profile }
  } catch (cause) {
    throw new LexServerError(
      404,
      {
        error: 'NotFound',
        message: 'Profile not found',
      },
      {
        'cache-control': 'max-age=600',
      },
      { cause },
    )
  }
})
```

Error responses follow the XRPC format:

```json
{
  "error": "NotFound",
  "message": "Profile not found"
}
```

### LexServerAuthError

`LexServerAuthError` extends `LexServerError` with `WWW-Authenticate` header support:

```typescript
import { LexServerAuthError } from '@atproto/lex-server'

throw new LexServerAuthError('AuthenticationRequired', 'Invalid credentials', {
  Bearer: { realm: 'api' },
})
```

This returns a 401 response with the `WWW-Authenticate` header.

### Error Handler Callback

Use `onHandlerError` to log or report unexpected errors:

```typescript
const router = new LexRouter({
  onHandlerError: async ({ error, request, method }) => {
    // Log errors (excluding expected abort signals)
    console.error(`Error in ${method.nsid}:`, error)
    await reportToSentry(error)
  },
})
```

> [!NOTE]
>
> The callback is only invoked for unexpected errors, not for `LexError` instances or request aborts.

## Node.js Server

The `@atproto/lex-server/nodejs` subpath provides Node.js-specific utilities.

### serve()

Start a server and begin listening:

```typescript
import { serve } from '@atproto/lex-server/nodejs'

const server = await serve(router, { port: 3000 })
console.log('Server listening on port 3000')

// Graceful shutdown
await server.terminate()
```

The server supports `AsyncDisposable`:

```typescript
await using server = await serve(router, { port: 3000 })
// Server is automatically terminated when scope exits
```

Options:

```typescript
type StartServerOptions = {
  port?: number
  host?: string
  gracefulTerminationTimeout?: number // ms to wait for connections to close
}
```

### createServer()

Create a server without starting it:

```typescript
import { createServer } from '@atproto/lex-server/nodejs'

const server = createServer(router, {
  gracefulTerminationTimeout: 5000,
})

server.listen(3000, () => {
  console.log('Server listening')
})
```

### toRequestListener()

Convert a handler to an Express/Connect-compatible middleware:

```typescript
import express from 'express'
import { toRequestListener } from '@atproto/lex-server/nodejs'

const app = express()

// Mount the XRPC router
app.use(toRequestListener(router.fetch))

app.listen(3000)
```

### upgradeWebSocket()

Required for WebSocket subscription support in Node.js:

```typescript
import { LexRouter } from '@atproto/lex-server'
import { upgradeWebSocket } from '@atproto/lex-server/nodejs'

const router = new LexRouter({ upgradeWebSocket })
```

## Advanced Usage

### Custom Response Objects

Return a `Response` object for full control over the response (disables any kind
of validation of the body):

```typescript
router.add(schema, async ({ params }) => {
  if (params.redirect) {
    return Response.redirect('https://example.com', 302)
  }

  return Response.json(
    { custom: true },
    {
      status: 201,
      headers: {
        'X-Custom-Header': 'value',
      },
    },
  )
})
```

### Response Headers

Add headers to responses:

```typescript
router.add(schema, async ({ params }) => {
  return {
    body: { data: 'value' },
    headers: {
      'Cache-Control': 'public, max-age=3600',
      'X-Request-Id': crypto.randomUUID(),
    },
  }
})
```

### Connection Info

Access network connection information:

```typescript
router.add(schema, async ({ connection }) => {
  console.log('Remote address:', connection?.remoteAddr?.hostname)
  console.log('Local address:', connection?.localAddr?.hostname)
  return { body: { status: 'ok' } }
})
```

Connection info structure:

```typescript
type ConnectionInfo = {
  localAddr?: {
    hostname: string
    port: number
    transport: 'tcp' | 'udp'
  }
  remoteAddr?: {
    hostname: string
    port: number
    transport: 'tcp' | 'udp'
  }
}
```

## License

MIT or Apache2

---

### Overview (`packages/lex/lex/README.md`)

Type-safe Lexicon tooling for AT Protocol data.

- Fetch and manage Lexicon schemas, generate TypeScript validators
- Compile-time and runtime type safety for AT Protocol data structures
- Fully typed XRPC client with authentication support
- Tree-shaking and composition friendly

```typescript
// Build and validate data with generated utilities

const newPost = app.bsky.feed.post.$build({
  text: 'Hello, world!',
  createdAt: new Date().toISOString(),
})

app.bsky.actor.profile.$validate({
  $type: 'app.bsky.actor.profile',
  displayName: 'Ha'.repeat(32) + '!',
}) // Error: grapheme too big (maximum 64, got 65) at $.displayName
```

```typescript
// Trivially make type-safe XRPC requests towards a service

const profile = await xrpc('https://api.bsky.app', app.bsky.actor.getProfile, {
  params: { actor: 'pfrazee.com' },
})
```

```typescript
// Manipulate records with the Client API in the context of an authenticated session

const client = new Client(oauthSession)

await client.create(app.bsky.feed.post, {
  text: 'Hello, world!',
  createdAt: new Date().toISOString(),
})

const posts = await client.list(app.bsky.feed.post, { limit: 10 })
```




- [Quick Start](#quick-start)
- [Lexicon Schemas](#lexicon-schemas)
- [TypeScript Schemas](#typescript-schemas)
  - [Generated Schema Structure](#generated-schema-structure)
  - [Type definitions](#type-definitions)
  - [Building data](#building-data)
  - [Validation Helpers](#validation-helpers)
    - [Record / typed-object helpers](#record--typed-object-helpers)
      - [`$type` - Type Identifier](#type---type-identifier)
      - [`$build(data)` - Build with Defaults](#builddata---build-with-defaults)
      - [`$isTypeOf(data)` - Type Discriminator](#istypeofdata---type-discriminator)
    - [Universal validation helpers](#universal-validation-helpers)
      - [`$matches(data)` - Type Guard](#matchesdata---type-guard)
      - [`$assert(data)` - Type-Narrowing Assertion](#assertdata---type-narrowing-assertion)
      - [`$parse(data)` - Parse and Validate](#parsedata---parse-and-validate)
      - [`$validate(data)` - Validate a value against the schema](#validatedata---validate-a-value-against-the-schema)
      - [`$safeParse(data, options?)` - Parse a value against a schema and get the resulting value](#safeparsedata-options---parse-a-value-against-a-schema-and-get-the-resulting-value)
- [Data Model](#data-model)
  - [Types](#types)
  - [JSON Encoding](#json-encoding)
  - [CBOR Encoding](#cbor-encoding)
- [Making simple XRPC Requests](#making-simple-xrpc-requests)
- [Client API](#client-api)
  - [Creating a Client](#creating-a-client)
    - [Unauthenticated Client](#unauthenticated-client)
    - [Authenticated Client with OAuth](#authenticated-client-with-oauth)
    - [Authenticated Client with Password](#authenticated-client-with-password)
    - [Client with Service Proxy (authenticated only)](#client-with-service-proxy-authenticated-only)
    - [Validation and Strictness Options](#validation-and-strictness-options)
  - [Core Methods](#core-methods)
    - [`client.call()`](#clientcall)
    - [`client.create()`](#clientcreate)
    - [`client.get()`](#clientget)
    - [`client.put()`](#clientput)
    - [`client.delete()`](#clientdelete)
    - [`client.list()`](#clientlist)
    - [`client.applyWrites()`](#clientapplywrites)
  - [Error Handling](#error-handling)
    - [Safe Methods](#safe-methods)
    - [XrpcFailure Type](#xrpcfailure-type)
  - [Authentication Methods](#authentication-methods)
    - [`client.did`](#clientdid)
    - [`client.assertAuthenticated()`](#clientassertauthenticated)
    - [`client.assertDid`](#clientassertdid)
  - [Labeler Configuration](#labeler-configuration)
  - [Low-Level XRPC](#low-level-xrpc)
- [Utilities](#utilities)
  - [Datetime Strings](#datetime-strings)
- [Advanced Usage](#advanced-usage)
  - [Workflow Integration](#workflow-integration)
    - [Development Workflow](#development-workflow)
  - [Tree-Shaking](#tree-shaking)
    - [Namespace notation](#namespace-notation)
    - [Explicit `.main` reference](#explicit-main-reference)
    - [Direct named import from the schema file](#direct-named-import-from-the-schema-file)
    - [Default import (recommended)](#default-import-recommended)
    - [Drawbacks of the default export](#drawbacks-of-the-default-export)
    - [Summary](#summary)
  - [Blob references](#blob-references)
    - [TypedBlobRef: The Current Standard](#typedblobref-the-current-standard)
    - [LegacyBlobRef: Historical Format](#legacyblobref-historical-format)
    - [Working with Both Formats](#working-with-both-formats)
  - [Actions](#actions)
    - [What are Actions?](#what-are-actions)
    - [Using Actions](#using-actions)
    - [Composing Multiple Operations](#composing-multiple-operations)
    - [Higher-Order Actions](#higher-order-actions)
  - [Using a Single Client for Multiple Services](#using-a-single-client-for-multiple-services)
  - [Building Library-Style APIs with Actions](#building-library-style-apis-with-actions)
    - [Creating Posts](#creating-posts)
    - [Following Users](#following-users)
    - [Updating Profile with Retry Logic](#updating-profile-with-retry-logic)
    - [Packaging Actions as a Library](#packaging-actions-as-a-library)
    - [Best Practices for Actions](#best-practices-for-actions)
  - [Standard Schema Compatibility](#standard-schema-compatibility)
  - [Validating Generic Schemas with `$check`](#validating-generic-schemas-with-check)
- [License](#license)



## Quick Start

**1. Install Lexicons**

Install the Lexicon schemas you need for your application:

```bash
lex install app.bsky.feed.post app.bsky.feed.like
```

This creates:

- `lexicons.json` - manifest tracking installed Lexicons and their versions (CIDs)
- `lexicons/` - directory containing the Lexicon JSON files

> [!NOTE]
>
> The `lex` command might conflict with other binaries installed on your system.
> If that happens, you can also run the CLI using `ts-lex`, `pnpm exec lex` or
> `npx @atproto/lex`.

**2. Verify and commit installed Lexicons**

Make sure to commit the `lexicons.json` manifest and the `lexicons/` directory containing the JSON files to version control.

```bash
git add lexicons.json lexicons/
git commit -m "Install Lexicons"
```

**3. Build TypeScript schemas**

Generate TypeScript schemas from the installed Lexicons:

```bash
lex build
```

This generates TypeScript files in `./src/lexicons` (by default) with type-safe validation, type guards, and builder utilities.

> [!TIP]
>
> If you wish to customize the output location or any other build options, pass the appropriate flags to the `lex build` command. See the [TypeScript Schemas](#typescript-schemas) section for available options.

> [!NOTE]
>
> The generated TypeScript files don't need to be committed to version control. Instead, they can be generated during your project's build step. See [Workflow Integration](#workflow-integration) for details.
>
> To avoid committing generated files, add the output directory to your `.gitignore`:
>
> ```bash
> echo "/src/lexicons" >> .gitignore
> ```

**4. Use in your code**

```typescript
import { xrpc } from '@atproto/lex'
import { app } from './lexicons/index.js'

const profile = await xrpc('https://api.bsky.app', app.bsky.actor.getProfile, {
  params: { actor: 'pfrazee.com' },
})
```

## Lexicon Schemas

The `lex install` command resolves Lexicon schemas and manages them locally (in the `lexicons/` directory by default). It also updates the `lexicons.json` manifest file to track installed Lexicons and their versions.

```bash
# Install Lexicons and update lexicons.json (default behavior)
lex install app.bsky.feed.post

# Install all Lexicons from lexicons.json manifest
lex install

# Install specific Lexicons without updating manifest
lex install --no-save app.bsky.feed.post app.bsky.actor.profile

# Update (re-fetch) all installed Lexicons to latest versions
lex install --update

# Fetch any missing Lexicons and verify against manifest
lex install --ci
```

Options:

- `--manifest <path>` - Path to lexicons.json manifest file (default: `./lexicons.json`)
- `--no-save` - Don't update lexicons.json with installed lexicons (save is enabled by default)
- `--update` - Update all installed lexicons to their latest versions by re-resolving and re-installing them
- `--ci` - Error if the installed lexicons do not match the CIDs in the lexicons.json manifest
- `--lexicons <dir>` - Directory containing lexicon JSON files (default: `./lexicons`)

### Local resolution (`resolvers`)

Beyond the network, `lexicons.json` can declare an ordered `resolvers` array of
local override strategies, consulted (in order, first match wins) for every
dependency before the network fallback. This lets a package vendor Lexicons from
a local directory. Local-file resolutions are **symlinked** into the `lexicons/`
directory (not copied) and locked with a `file://` URI relative to the manifest.

```jsonc
{
  "version": 1,
  "lexicons": ["com.example.foo"],
  "resolvers": [
    // path relative to lexicons.json; optional include/exclude NSID globs
    { "type": "directory", "path": "../../lexicons" },
    // use a specific repo for resolving certain NSIDs (instead of performing the _lexicon network resolution of the authority)
    { "type": "repo", "repo": "bsky.app", "include": ["app.bsky.*"] },
  ],
  "resolutions": {/* ...file:// or at:// locks... */},
}
```

See the [`@atproto/lex-installer` README](https://github.com/bluesky-social/atproto/blob/main/packages/lex/lex-installer/README.md) for the full resolution order and the planned `repo` resolver.

## TypeScript Schemas

After installing Lexicon JSON files, use the `lex build` command to generate TypeScript schemas. These generated schemas provide type-safe validation, type guards, and builder utilities for working with AT Protocol data structures.

```bash
lex build --lexicons ./lexicons --out ./src/lexicons
```

Options:

- `--lexicons <dir>` - Directory containing lexicon JSON files (default: `./lexicons`)
- `--out <dir>` - Output directory for generated TypeScript (default: `./src/lexicons`)
- `--clear` - Clear output directory before generating
- `--override` - Override existing files (has no effect with --clear)
- `--no-pretty` - Don't run prettier on generated files (prettier is enabled by default)
- `--ignore-errors` - Skip files that fail to parse or compile instead of aborting the build
- `--ignore-invalid-lexicons` - Skip lexicon files that fail validation instead of exiting with an error
- `--exclude <patterns...>` - List of strings or regex patterns to exclude lexicon documents by their IDs
- `--include <patterns...>` - List of strings or regex patterns to include lexicon documents by their IDs
- `--lib <package>` - Package name of the library to import the lex schema utility "l" from (default: `@atproto/lex`)
- `--import-ext <ext>` - File extension to use for import statements in generated files (default: `.js`). Use `--import-ext ""` to generate extension-less imports
- `--file-ext <ext>` - File extension to use for generated files (default: `.ts`)
- `--index-file` - Generate an "index" file that re-exports all root-level namespaces (disabled by default)
- `--defs-export` - When some definitions conflict with child namespaces, export lexicon definitions under a separate `$defs` namespace (e.g. `com.example.foo.$defs`)
- `--no-default-export` - Disable generation of a `default` export of the `main` schema in each schema's namespace file (default exports are enabled by default; see [Tree-Shaking](#tree-shaking))

### Generated Schema Structure

Each Lexicon generates a TypeScript module with:

- **Type definitions** - TypeScript types extracted from the schema
- **Schema instances** - Runtime validation objects with methods
- **Exported utilities** - Convenience functions for common operations

### Type definitions

You can extract TypeScript types from the generated schemas for use in you application:

```typescript
import * as app from './lexicons/app.js'

function renderPost(p: app.bsky.feed.post.Main) {
  console.log(p.$type) // 'app.bsky.feed.post'
  console.log(p.text)
}
```

Token definitions also export their string value in PascalCase, using the same
name as their string literal type. The token's camelCase export remains the
runtime schema:

```typescript
import * as app from './lexicons/app.js'

const requestLess = app.bsky.feed.defs.RequestLess
// 'app.bsky.feed.defs#requestLess'

type RequestLess = app.bsky.feed.defs.RequestLess

app.bsky.feed.defs.requestLess // TokenSchema
app.bsky.feed.defs.requestLess.$validate('foo') // throws
app.bsky.feed.defs.requestLess.$safeValidate('foo') // ValidationFailure
app.bsky.feed.defs.requestLess.$token === requestLess // true
```

### Building data

It is recommended to use the generated builders to create data that conforms to the schema. TypeScript ensures that all required fields are present at compile time.

```typescript
import { l } from '@atproto/lex'
import * as app from './lexicons/app.js'

// variable type will be inferred as "app.bsky.feed.post.Main"
const post = app.bsky.feed.post.$build({
  // No need to specify $type when using $build
  text: 'Hello, world!',
  createdAt: l.currentDatetimeString(),
})

// For runtime validation, use $parse()/$validate() instead
const postWithDefaults = app.bsky.feed.post.$parse(post)
app.bsky.feed.post.$validate(post)
```

### Validation Helpers

Generated namespaces expose a handful of `$`-prefixed helpers bound to the namespace's `main` schema. They come in two groups:

- [**Universal validation helpers**](#universal-validation-helpers) are available on every schema's `main`: `$matches`, `$assert`, `$check`, `$parse`, `$safeParse`, `$validate`, `$safeValidate` (and `$cast` / `$ifMatches`). These work for records, typed objects, queries, procedures, and subscriptions.
- [**Record / typed-object helpers**](#record--typed-object-helpers) are only emitted for record and typed-object schemas: `$type`, `$build`, `$isTypeOf`.

In addition, every generated namespace file exports a top-level `$nsid` constant containing the NSID of the lexicon document:

```typescript
import * as app from './lexicons/app.js'

console.log(app.bsky.feed.defs.$nsid) // 'app.bsky.feed.defs'
```

The Schema instance itself (for example `app.bsky.feed.post.main`) also exposes the underlying methods both with and without the `$` prefix (e.g. `main.parse()` and `main.$parse()`).

#### Record / typed-object helpers

##### `$type` - Type Identifier

Returns the `$type` string of the schema (only available on record and typed-object schemas):

```typescript
import * as app from './lexicons/app.js'

console.log(app.bsky.feed.post.$type) // 'app.bsky.feed.post'
console.log(app.bsky.actor.defs.profileViewBasic.$type) // 'app.bsky.actor.defs#profileViewBasic'
```

Prefer `$type` over hard-coding the equivalent string literal in your code. The constant is emitted exactly once per schema in the generated namespace file, so every reference reuses the same string instance. Inlining `'app.bsky.feed.post'` everywhere instead leaks the same string into every call site, increases bundle size, and creates a typo-prone source of drift between your code and the schema.

##### `$build(data)` - Build with Defaults

Builds data by adding the `$type` property and properly types the result. This also allows to declare a variable with the correct type without having to explicitly specify it.

```typescript
import { l } from '@atproto/lex'
import * as app from './lexicons/app.js'

// The type of the "like" variable will be "app.bsky.feed.like.Main" (no need to explicitly specify the type)
const like = app.bsky.feed.like.$build({
  subject: {
    uri: 'at://did:plc:abc/app.bsky.feed.post/123',
    cid: 'bafyrei...',
  },
  createdAt: l.currentDatetimeString(),
})
```

> [!NOTE]
>
> `$build()` does not perform validation, and expects properly typed input data - use `$parse()` if you need validation.

##### `$isTypeOf(data)` - Type Discriminator

Discriminates (pre-validated) data based on its `$type` property, without re-validating. This is especially useful when working with union types:

```typescript
import { l } from '@atproto/lex'
import * as app from './lexicons/app.js'

declare const data:
  app.bsky.feed.post.Main | app.bsky.feed.like.Main | l.Unknown$TypedObject

// Discriminate by $type without re-validating
if (app.bsky.feed.post.$isTypeOf(data)) {
  // data is a post
}
```

#### Universal validation helpers

##### `$matches(data)` - Type Guard

Returns `true` if data matches the schema, `false` otherwise. Acts as a TypeScript type guard:

```typescript
import { l } from '@atproto/lex'
import * as app from './lexicons/app.js'

const data: unknown = {
  $type: 'app.bsky.feed.post',
  text: 'Hello!',
  createdAt: l.currentDatetimeString(),
}

if (app.bsky.feed.post.$matches(data)) {
  // TypeScript knows data is a Post here
  console.log(data.text)
}
```

> [!NOTE]
>
> Performs validation so [`$isTypeOf`](#istypeofdata---type-discriminator) is preferred for pre-validated & properly typed data.

##### `$assert(data)` - Type-Narrowing Assertion

Throws if `data` does not match the schema. When the schema is statically known (e.g. `app.bsky.feed.post`), TypeScript narrows the type of `data` after the call:

```typescript
import { l } from '@atproto/lex'
import * as app from './lexicons/app.js'

const data: unknown = {
  $type: 'app.bsky.feed.post',
  text: 'Hello!',
  createdAt: l.currentDatetimeString(),
}

app.bsky.feed.post.$assert(data)

// TypeScript now knows data is app.bsky.feed.post.Main
console.log(data.text)
```

For library code that operates on a schema parameter whose type cannot be fully expressed, see [Validating Generic Schemas with `$check`](#validating-generic-schemas-with-check).

##### `$parse(data)` - Parse and Validate

Validates and returns typed data, throwing an error if validation fails:

```typescript
import { l } from '@atproto/lex'
import * as app from './lexicons/app.js'

try {
  const post = app.bsky.feed.post.$parse({
    $type: 'app.bsky.feed.post',
    text: 'Hello!',
    createdAt: l.currentDatetimeString(),
  })

  // post is now typed and validated
  console.log(post.text)
} catch (error) {
  console.error('Validation failed:', error)
}
```

> [!NOTE]
>
> The `$parse` method will apply defaults defined in the schema for optional fields, as well as data coercion (e.g., CID strings to Cid types). This means that the returned value might be different from the input data if defaults were applied. Use `$validate()` for value validation.

##### `$validate(data)` - Validate a value against the schema

Validates an existing value against a schema, returning the value itself if, and only if, it already matches the schema (ie. without applying defaults or coercion).

```typescript
import { l } from '@atproto/lex'
import * as app from './lexicons/app.js'

const value = {
  $type: 'app.bsky.feed.post',
  text: 'Hello!',
  createdAt: l.currentDatetimeString(),
}

// Throws if no valid
const result = app.bsky.feed.post.$validate(value)

value === result // true
```

##### `$safeParse(data, options?)` - Parse a value against a schema and get the resulting value

Returns a detailed validation result object without throwing:

```typescript
import { l } from '@atproto/lex'
import * as app from './lexicons/app.js'

const result = app.bsky.feed.post.$safeParse({
  $type: 'app.bsky.feed.post',
  text: 'Hello!',
  createdAt: l.currentDatetimeString(),
})

if (result.success) {
  console.log('Valid post:', result.value)
} else {
  console.error('Validation failed:', result.error)
}
```

All schema methods that perform validation (`$parse`, `$safeParse`, `$validate`, `$safeValidate`) accept an optional `{ strict }` option. When `strict` is `false`, validation becomes more lenient: datetime string format checks are relaxed (e.g. datetimes without timezones are accepted; other string formats remain strict), blob MIME type and size constraints are not enforced, non-raw CIDs are allowed in blob references, and legacy blob reference format (objects with `cid` and `mimeType` properties) is accepted. This is primarily used internally by the XRPC client when `strictResponseProcessing` is disabled, but can also be used directly:

```typescript
// Strict mode (default) - rejects datetime without timezone
app.bsky.feed.post.$safeParse(data) // { strict: true } is the default

// Non-strict mode - accepts more lenient data
app.bsky.feed.post.$safeParse(data, { strict: false })
```

## Data Model

The AT Protocol uses a [data model](https://atproto.com/specs/data-model) that extends JSON with two additional data structures: **CIDs** (content-addressed links) and **bytes** (for raw data). This data model can be encoded either as JSON for XRPC (HTTP API) or as [CBOR](https://dasl.ing/drisl.html) for storage and authentication (see [`@atproto/lex-cbor`](../lex-cbor)).

### Types

The package exports TypeScript types and type guards for working with the data model:

```typescript
import type {
  LexValue,
  LexMap,
  LexScalar,
  TypedLexMap,
  Cid,
} from '@atproto/lex'
import { isLexValue, isLexMap, isTypedLexMap, isCid } from '@atproto/lex'

// LexScalar: number | string | boolean | null | Cid | Uint8Array
// LexValue:  LexScalar | LexValue[] | { [key: string]?: LexValue }
// LexMap:    { [key: string]?: LexValue }
// TypedLexMap: LexMap & { $type: string }
// Cid: Content Identifier (link by hash)

if (isTypedLexMap(data)) {
  console.log(data.$type) // some string
}
```

### JSON Encoding

In JSON, CIDs are represented as `{"$link": "bafyrei..."}` and bytes as `{"$bytes": "base64..."}`. This package provides utilities to parse and stringify data model values to/from JSON:

```typescript
import { Cid, lexParse, lexStringify, jsonToLex, lexToJson } from '@atproto/lex'

// Parse JSON string → data model (decodes $link and $bytes)
const parsed = lexParse<{
  ref: Cid
  data: Uint8Array
}>(`{
  "ref": { "$link": "bafyrei..." },
  "data": { "$bytes": "SGVsbG8sIHdvcmxkIQ==" }
}`)

assert(isCid(parsed.ref))
assert(parsed.data instanceof Uint8Array)

const someCid = lexParse<Cid>('{"$link": "bafyrei..."}')
const someBytes = lexParse<Uint8Array>('{"$bytes": "SGVsbG8sIHdvcmxkIQ=="}')

// Data model → JSON string (encodes CIDs and bytes)
const json = lexStringify({ ref: someCid, data: someBytes })

// Convert between parsed JSON objects and data model values
const lex = jsonToLex({
  ref: { $link: 'bafyrei...' }, // Converted to Cid
  data: { $bytes: 'SGVsbG8sIHdvcmxkIQ==' }, // Converted to Uint8Array
})

const obj = lexToJson({
  ref: someCid, // Converted to { $link: string }
  data: someBytes, // Converted to { $bytes: string }
})
```

### CBOR Encoding

Use `@atproto/lex-cbor` to encode/decode the data model to/from CBOR ([DRISL](https://dasl.ing/drisl.html)) format for storage and authentication:

```typescript
import { encode, decode } from '@atproto/lex-cbor'
import type { LexValue } from '@atproto/lex'

// Encode data model to CBOR bytes
const cborBytes = encode(someLexValue)

// Decode CBOR bytes to data model
const lexValue: LexValue = decode(cborBytes)
```

## Making simple XRPC Requests

[XRPC](https://atproto.com/specs/xrpc) (short for "Lexicon RPC") is the set of HTTP conventions used by AT Protocol for client-server and server-server communication. Endpoints follow the pattern `/xrpc/<nsid>`, where the NSID maps to a Lexicon schema that defines the request and response types. XRPC has three method types: **queries** (HTTP GET) for read operations, **procedures** (HTTP POST) for mutations and **subscriptions** (WebSockets) for real-time updates.

The `xrpc()` and `xrpcSafe()` functions can be used to make simple XRPC requests. They are typically used in places that don't require an authenticated session, or when more granular control over the request/response is needed. For most use cases, the `Client` API provides a more ergonomic way to work with XRPC in the context of an authenticated session.

```typescript
import { xrpc, xrpcSafe } from '@atproto/lex'
import * as com from './lexicons/com.js'

const response = await xrpc(
  'https://bsky.network',
  com.atproto.identity.resolveHandle,
  {
    params: { handle: 'atproto.com' },
    headers: { 'user-agent': 'MyApp/1.0.0' },
  },
)

response.status // number
response.headers // Headers
response.body.did // `did:${string}:${string}`

// Or use the safe variant (returns errors instead of throwing)
const result = await xrpcSafe(
  'https://bsky.network',
  com.atproto.identity.resolveHandle,
  {
    params: { handle: 'atproto.com' },
    signal: AbortSignal.timeout(5000), // Abort after 5 seconds
  },
)

if (result.success) {
  console.log(result.body)
} else {
  console.error(result.error) // XRPC error code
  console.error(result.message) // Error message
}
```

Both `xrpc()` and `xrpcSafe()` accept `validateRequest`, `validateResponse`, and `strictResponseProcessing` options to control validation and strictness per-call. See [Validation and Strictness Options](#validation-and-strictness-options) for details.

## Client API

The `Client` class provides high-level helpers for common AT Protocol "repo" operations: `create()`, `get()`, `put()`, `delete()`, `list()`, `uploadBlob()`, and more. A `Client` instance is typically useful for making requests in the context of an authenticated user session, as it automatically handles headers and provides default values based on the authenticated user's DID.

A `Client` instance is also useful to encapsulate configuration for a specific service, by specifying the `service` option (for proxying) and `labelers` option (for content labeling). These act as _defaults_ for the client's requests and can be overridden — or disabled with `null` — on a per-request basis, allowing a single client to talk both to a proxied service (e.g. an AppView) and directly to the user's PDS.

### Creating a Client

#### Unauthenticated Client

Just provide the service URL:

```typescript
import { Client } from '@atproto/lex'

const client = new Client('https://public.api.bsky.app')
```

#### Authenticated Client with OAuth

```typescript
import { Client } from '@atproto/lex'
import { OAuthClient } from '@atproto/oauth-client-node'

// Setup OAuth client (see @atproto/oauth-client documentation)
const oauthClient = new OAuthClient({/* ... */})
const session = await oauthClient.restore(userDid)

// Create authenticated client
const client = new Client(session)
```

For detailed OAuth setup, see the [@atproto/oauth-client](../../../oauth/oauth-client) documentation.

#### Authenticated Client with Password

For CLI tools, scripts, and bots, you can use password-based authentication with [`@atproto/lex-password-session`](../lex-password-session):

```typescript
import { Client } from '@atproto/lex'
import { PasswordSession } from '@atproto/lex-password-session'

const session = await PasswordSession.login({
  service: 'https://bsky.social',
  identifier: 'alice.bsky.social',
  password: 'xxxx-xxxx-xxxx-xxxx', // App password
  onUpdated: (data) => saveToStorage(data),
  onDeleted: (data) => clearStorage(data.did),
})

const client = new Client(session)
```

For detailed password session setup, see the [@atproto/lex-password-session](../lex-password-session) documentation.

#### Client with Service Proxy (authenticated only)

```typescript
import { Client } from '@atproto/lex'

// Route requests through a specific service
const client = new Client(session, {
  service: 'did:web:api.bsky.app#bsky_appview',
})
```

#### Validation and Strictness Options

The `Client` constructor accepts options to control request/response validation and how invalid Lex data is handled. These defaults apply to all XRPC calls made through the client, and can be overridden per-call via `client.call()`, `client.xrpc()` or `client.xrpcSafe()`.

```typescript
const client = new Client(session, {
  // Validate requests against the method's input schema (default: false)
  validateRequest: true,

  // Validate responses against the method's output schema (default: true)
  validateResponse: true,

  // Strictly process responses according to Lex encoding rules. When set to
  // false, accepts responses containing invalid Lex data such as floats or
  // malformed $bytes/$link objects (default: true)
  strictResponseProcessing: false,
})
```

- **`validateRequest`** — When `true`, outgoing request bodies are validated against the Lexicon input schema before sending. Useful in development to catch errors early. Default: `false`.
- **`validateResponse`** — When `true`, incoming response bodies are validated against the Lexicon output schema. Disabling this can improve performance when you trust the upstream service. Default: `true`.
- **`strictResponseProcessing`** — When `true` (default), the client will strictly process responses according to Lex encoding rules, rejecting responses containing invalid Lex data (e.g. floating-point numbers, malformed `$bytes` or `$link` objects). When `false`, the client accepts such responses in a lenient mode: invalid values are returned as-is rather than being rejected or converted, `datetime` string format checks become more lenient (e.g. datetimes without timezones are accepted) while other string formats remain strict, blob MIME type and size constraints are not enforced, and legacy blob reference format (objects with `cid` and `mimeType` properties) is accepted. Default: `true`.

### Core Methods

#### `client.call()`

Call procedures or queries defined in Lexicons.

```typescript
import * as app from './lexicons/app.js'

// Query (GET request)
const profile = await client.call(app.bsky.actor.getProfile, {
  actor: 'pfrazee.com',
})

// Procedure (POST request)
const result = await client.call(app.bsky.feed.sendInteractions, {
  interactions: [/* ... */],
})

// With options
const timeline = await client.call(
  app.bsky.feed.getTimeline,
  {
    limit: 50,
  },
  {
    signal: abortSignal,
  },
)
```

#### `client.create()`

Create a new record un the authenticated user's repo.

```typescript
import { l } from '@atproto/lex'
import * as app from './lexicons/app.js'

const result = await client.create(app.bsky.feed.post, {
  text: 'Hello, world!',
  createdAt: l.currentDatetimeString(),
})

console.log(result.uri) // at://did:plc:...
console.log(result.cid)
```

Options:

- `rkey` - Custom record key (auto-generated if not provided)
- `validate` - Tri-state instruction to the PDS. `true` forces server-side schema validation, `false` explicitly disables it, and `undefined` (default) lets the PDS decide (it validates only collections whose schemas it knows)
- `validateRequest` - Validate the record locally against schema before submitting the request
- `swapCommit` - CID for optimistic concurrency control

#### `client.get()`

Retrieve a record.

```typescript
import * as app from './lexicons/app.js'

// No need to specify the "rkey" for records with literal keys (e.g. profile)
const profile = await client.get(app.bsky.actor.profile)

console.log(profile.displayName)
console.log(profile.description)
```

For records with non-literal keys:

```typescript
const post = await client.get(app.bsky.feed.post, {
  rkey: '3jxf7z2k3q2',
})
```

#### `client.put()`

Update an existing record.

```typescript
import * as app from './lexicons/app.js'

await client.put(app.bsky.actor.profile, {
  displayName: 'New Name',
  description: 'Updated bio',
})
```

Options:

- `rkey` - Record key (required for non-literal keys)
- `validate` - Tri-state instruction to the PDS. `true` forces server-side schema validation, `false` explicitly disables it, and `undefined` (default) lets the PDS decide (it validates only collections whose schemas it knows)
- `validateRequest` - Validate the record locally against schema before submitting the request
- `swapCommit` - Expected repo commit CID
- `swapRecord` - Expected record CID

#### `client.delete()`

Delete a record.

```typescript
import * as app from './lexicons/app.js'

await client.delete(app.bsky.feed.post, {
  rkey: '3jxf7z2k3q2',
})
```

#### `client.list()`

List records in a collection.

```typescript
import * as app from './lexicons/app.js'

const result = await client.list(app.bsky.feed.post, {
  limit: 50,
  reverse: true,
})

for (const record of result.records) {
  console.log(record.uri, record.value.text)
}

// Records that failed local schema validation are returned separately
for (const invalid of result.invalid) {
  console.warn('Invalid record:', invalid)
}

// Pagination
if (result.cursor) {
  const nextPage = await client.list(app.bsky.feed.post, {
    cursor: result.cursor,
    limit: 50,
  })
}
```

The result includes:

- `records` - Records that successfully validated against the schema
- `invalid` - Records returned by the server that failed local schema validation (raw `LexMap` values)
- `cursor` - Pagination cursor (if more results are available)

#### `client.applyWrites()`

Perform an atomic batch of create, update, and delete operations in a single request.

```typescript
import { l } from '@atproto/lex'
import * as app from './lexicons/app.js'

const response = await client.applyWrites((op) => [
  // Create a new post
  op.create(app.bsky.feed.post, {
    text: 'Hello, world!',
    createdAt: l.currentDatetimeString(),
  }),

  // Update profile
  op.update(app.bsky.actor.profile, {
    displayName: 'Alice',
    description: 'Updated bio',
  }),

  // Delete an existing post by rkey
  op.delete(app.bsky.feed.post, {
    rkey: '3jxf7z2k3q2',
  }),
])

// Check results
for (const result of response.body.results) {
  console.log(result.uri, result.cid)
}
```

Options:

- `repo` - Repository identifier (defaults to authenticated user's DID)
- `validate` - Tri-state instruction to the PDS. `true` forces server-side schema validation, `false` explicitly disables it, and `undefined` (default) lets the PDS decide (it validates only collections whose schemas it knows)
- `swapCommit` - CID for optimistic concurrency control

> [!NOTE]
>
> All operations in an `applyWrites()` call are atomic - they either all succeed or all fail together. This is useful for maintaining consistency when making multiple related changes.

### Error Handling

By default, all client methods throw errors when requests fail. For more ergonomic error handling, the client provides "Safe" variants that return errors instead of throwing them.

#### Safe Methods

The `xrpcSafe()` method catches errors and returns them as part of the result type instead of throwing:

#### XrpcFailure Type

The `xrpcSafe()` method returns a union type that includes the success case (`XrpcResponse`) and failure cases (`XrpcFailure`):

```
/* Detailed source-code truncated for AI context efficiency. */
```

The `XrpcFailure<M>` type is a union of three error classes:

1. **`XrpcResponseError`** - The server responded with a 4xx/5xx error status code. This is used for all error responses from the upstream server.

2. **`XrpcInvalidResponseError`** - The upstream server returned a 2xx/3xx that does not comply with XRPC specifications for successful responses. A sub-class, `XrpcResponseValidationError`, is used for payload schema validation failures specifically.

3. **`XrpcInternalError`** - Client-side errors (network failures, timeouts, etc.)

### Authentication Methods

#### `client.did`

Get the authenticated user's DID.

```typescript
const did = client.did // Returns Did | undefined
```

#### `client.assertAuthenticated()`

Assert that the client is authenticated (throws if not).

```typescript
client.assertAuthenticated()
// After this call, TypeScript knows client.did is defined
const did = client.did // Type: Did (not undefined)
```

#### `client.assertDid`

Get the authenticated user's DID, asserting that the client is authenticated.

```typescript
const did = client.assertDid // Type: Did (throws if not authenticated)
```

This is equivalent to calling `client.assertAuthenticated()` followed by accessing `client.did`, but provides a more concise way to get the DID when you know authentication is required.

### Labeler Configuration

Configure content labelers for moderation.

```typescript
import { Client } from '@atproto/lex'

// Global app-level labelers
Client.configure({
  appLabelers: ['did:plc:labeler1', 'did:plc:labeler2'],
})

// Client-specific labelers
const client = new Client(session, {
  labelers: ['did:plc:labeler3'],
})

// Add labelers dynamically
client.addLabelers(['did:plc:labeler4'])

// Replace all labelers
client.setLabelers(['did:plc:labeler5'])

// Clear labelers
client.clearLabelers()
```

The client's `labelers` act as a default for its requests. A per-request `labelers` option replaces that default for that request, and `labelers: null` disables the `atproto-accept-labelers` header entirely:

```typescript
// Uses the client's labelers
await client.call(app.bsky.actor.getProfile, { actor })

// Uses only 'did:plc:other' for this request
await client.xrpc(app.bsky.actor.getProfile, {
  params: { actor },
  labelers: ['did:plc:other'],
})

// No atproto-accept-labelers header for this request (except appLabelers,
// which are applied unless explicitly disabled)
await client.xrpc(app.bsky.actor.getProfile, {
  params: { actor },
  labelers: null,
})
```

App-level labelers (`appLabelers`) are always added to the `atproto-accept-labelers` header with the `;redact` param. They default to the static `Client.appLabelers` (set via `Client.configure()`), and can be overridden through the client's or a request's `appLabelers` option (`appLabelers: null` disables them for that client or request).

The same defaulting logic applies to the `service` option (`atproto-proxy` header): a per-request `service` replaces the client's default, and `service: null` disables proxying for that request.

### Low-Level XRPC

For advanced use cases, use `client.xrpc()` to get the full response (headers, status, body):

```typescript
import * as app from './lexicons/app.js'

const response = await client.xrpc(app.bsky.feed.getTimeline, {
  params: { limit: 50 },
  signal: abortSignal,
  headers: { 'custom-header': 'value' },
})

console.log(response.status)
console.log(response.headers)
console.log(response.body)
```

Validation and strictness options (`validateRequest`, `validateResponse`, `strictResponseProcessing`) can also be passed per-call to override the client defaults:

```typescript
const response = await client.xrpc(app.bsky.feed.getTimeline, {
  params: { limit: 50 },
  strictResponseProcessing: false, // Accept non-strict Lex data for this call
  validateResponse: false, // Skip schema validation for this call
})
```

## Utilities

Various utilities for working with CIDs, datetime strings, string lengths, language tags, and low-level JSON encoding are exported from the package:

```
/* Detailed source-code truncated for AI context efficiency. */
```

### Datetime Strings

Many AT Protocol records (such as posts, likes, and follows) include a `createdAt` field that expects a valid `DatetimeString`. While `new Date().toISOString()` produces a string that looks like a valid datetime, it is not guaranteed to always conform to the AT Protocol's [datetime format requirements](https://atproto.com/specs/lexicon#datetime) (for example, `Date` objects representing dates before year 0 or after year 9999 will produce non-conforming strings). To ensure correctness and type safety, use the `DatetimeString` utilities exported from `@atproto/lex`:

- **`toDatetimeString(date: Date)`** - Converts a `Date` object into a valid `DatetimeString`, throwing an `InvalidDatetimeError` if the date cannot be represented as a valid AT Protocol datetime.
- **`asDatetimeString(input: string)`** - Validates and casts an arbitrary string to `DatetimeString`, throwing an `InvalidDatetimeError` if the string does not conform.
- **`isDatetimeString(input)`** - Type guard that returns `true` if the input is a valid `DatetimeString`.
- **`ifDatetimeString(input)`** - Returns the input as a `DatetimeString` if valid, or `undefined` otherwise.
- **`currentDatetimeString()`** - Returns the current date and time as `DatetimeString`.

```typescript
import { l } from '@atproto/lex'

// Convert a Date object to a DatetimeString (or throws)
const someDate = new Date('2024-01-15T12:30:00Z')
const now = l.toDatetimeString(someDate)

// Get the current datetime as a DatetimeString
const now = l.currentDatetimeString()

// Validate and cast an existing string
const dt = l.asDatetimeString('2024-01-15T12:30:00.000Z')

// Type guard for conditional checks
if (l.isDatetimeString(someString)) {
  // someString is now typed as DatetimeString
}
```

## Advanced Usage

### Workflow Integration

#### Development Workflow

Add these scripts to your `package.json`:

```json
{
  "scripts": {
    "update-lexicons": "lex install --update --save",
    "postinstall": "lex install --ci",
    "prebuild": "lex build",
    "build": "# Your build command here"
  }
}
```

This ensures that:

1. Lexicons are verified against the manifest after every `npm install` or `pnpm install`.
2. TypeScript schemas are built before your project is built.
3. You can easily update lexicons with `npm run update-lexicons` or `pnpm update-lexicons`.

### Tree-Shaking

The generated TypeScript code is structured to be tree-shakeable, but the way you reference schemas has a meaningful impact on the final bundle size. There are several ways to refer to a generated schema, and each comes with different trade-offs.

#### Namespace notation

The most ergonomic style is to use a namespace import and reference schemas through dotted paths:

```typescript
import * as com from './lexicons/com.js'

await client.call(com.atproto.repo.getRecord, {/* ... */})
```

This style is convenient and reads naturally as it mirrors the NSID of the schema. However, it produces the largest bundles. From the bundler's point of view, `com.atproto.repo.getRecord` is the whole schema namespace (which contains the `main` schema as well as helpers, and any other definitions). The bundler cannot know that `client.call()` only consumes the `main` schema, so it has to keep the rest of the namespace alive in the bundle.

#### Explicit `.main` reference

You can mitigate the bundle-size cost by explicitly naming the `main` definition:

```typescript
import * as com from './lexicons/com.js'

await client.call(com.atproto.repo.getRecord.main, {/* ... */})
```

This lets the bundler drop the sibling definitions inside `getRecord` that aren't referenced. The drawback is that it leaks an implementation detail: the `main` segment of the path. In Lexicon, `main` is typically implicit:

- Records use a `$type` of `app.bsky.feed.post` (no `#main`)
- XRPC endpoints are exposed as `/xrpc/com.atproto.repo.getRecord` (no `main`)

So writing `.main` in application code feels verbose compared to how Lexicons are normally referred to.

#### Direct named import from the schema file

You can also import the `main` schema directly from the file that defines it:

```typescript
import { main as getRecord } from './lexicons/com/atproto/repo/getRecord.js'

await client.call(getRecord, {/* ... */})
```

This produces equally small bundles as the explicit `.main` reference, but it still surfaces the `main` identifier: you have to know to import `main` and likely rename it.

#### Default import (recommended)

To make the small-bundle path also the ergonomic path, every namespace file generated by `lex build` re-exports the `main` schema as its `default` export:

```typescript
// generated file: ./lexicons/com/atproto/repo/getRecord.js
export * from './getRecord.defs.js'
export { main as default } from './getRecord.defs.js'
```

This means you can write:

```typescript
import getRecord from './lexicons/com/atproto/repo/getRecord.js'
import post from './lexicons/app/bsky/feed/post.js'

await client.call(getRecord, {/* ... */})
await client.create(post, {/* ... */})
```

This is the most bundle-friendly style: the bundler only pulls in the `main` schema, and the import name doesn't have to mention `main` at all. This helps keeping application code aligned with how Lexicons are usually identified.

#### Drawbacks of the default export

The `default` re-export is enabled by default but has two minor drawbacks:

1. It is one additional property on the namespace module, which can very slightly increase bundle size if you also use the namespace in some places.
2. Any Lexicon document whose path segment is literally `default` (for example a hypothetical `com.example.records.default`) would conflict with the generated `default` export.

If either of these matters for your use case, you can disable the generation of `default` exports with the `--no-defaultExport` flag:

```bash
lex build --no-defaultExport
```

#### Summary

| Style                                                  | Bundle size | Ergonomics                   |
| ------------------------------------------------------ | ----------- | ---------------------------- |
| `com.atproto.repo.getRecord` (namespace)               | Largest     | Best: matches the NSID       |
| `com.atproto.repo.getRecord.main`                      | Small       | Leaks the `main` identifier  |
| `import { main as getRecord } from '.../getRecord.js'` | Small       | Verbose, leaks `main`        |
| `import getRecord from '.../getRecord.js'`             | Small       | Concise, no `main` in source |

For libraries and applications where bundle size matters (typically anything shipped to a browser), prefer the default-import style. For scripts, tests, and server-side code where the bundle size of generated schemas is not a concern, the namespace style is perfectly fine.

### Blob references

In AT Protocol, binary data (blobs) are referenced using blob references, which include metadata like MIME type and size. These references allow PDSs to determine which binary data ("files") is referenced by records.

#### TypedBlobRef: The Current Standard

The current standard format for blob references is `TypedBlobRef`:

```typescript
import { TypedBlobRef } from '@atproto/lex'

const blobRef: TypedBlobRef = {
  $type: 'blob',
  ref: parseCid('bafybeihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku'),
  mimeType: 'image/png',
  size: 12345,
}
```

**When creating new blobs**, always use the `TypedBlobRef` format. This is the format returned by `client.uploadBlob()` and expected by PDS endpoints.

#### LegacyBlobRef: Historical Format

Historically, blob references used a simpler format without the `$type` property:

```typescript
type LegacyBlobRef = {
  cid: string // CID as a string (not a Cid object)
  mimeType: string // No size property
}
```

**Legacy blob references still exist in the AT Protocol network** in older records created before the format migration. While new blobs should always be created as `TypedBlobRef`, your code must be prepared to handle both formats when reading existing data.

#### Working with Both Formats

The `BlobRef` type is a union that accepts both formats:

```typescript
import {
  BlobRef,
  isBlobRef,
  isTypedBlobRef,
  isLegacyBlobRef,
} from '@atproto/lex'

// When reading data, always use BlobRef to handle both formats
function processBlobRef(blob: BlobRef) {
  if (isTypedBlobRef(blob)) {
    console.log('Modern blob:', blob.ref, blob.mimeType, blob.size)
  } else if (isLegacyBlobRef(blob)) {
    console.log('Legacy blob:', blob.cid, blob.mimeType)
  }
}

// Or use the isBlobRef type guard which accepts both
if (isBlobRef(value)) {
  // value is BlobRef (either TypedBlobRef or LegacyBlobRef)
}
```

Helper functions work with both formats:

```typescript
import {
  getBlobCid,
  getBlobCidString,
  getBlobMime,
  getBlobSize,
} from '@atproto/lex'

// These utilities work with both TypedBlobRef and LegacyBlobRef
const cid = getBlobCid(blobRef) // Returns Cid object
const cidStr = getBlobCidString(blobRef) // Returns string (optimized)
const mime = getBlobMime(blobRef) // Returns mimeType
const size = getBlobSize(blobRef) // Returns number | undefined (legacy refs lack size)
```

> [!IMPORTANT]
>
> **Validation behavior with legacy blobs:**
>
> - In **strict mode** (`strict: true`, the default): Legacy blob references are rejected during validation. Use this mode when you control the data source and expect only modern blobs.
> - In **non-strict mode** (`strict: false`): Legacy blob references are accepted. This mode is used automatically when `strictResponseProcessing: false` is set on the Client, allowing your application to handle older records from the network gracefully.
>
> ```typescript
> // Strict mode (default) - rejects legacy blobs
> schema.$safeParse(data) // { strict: true }
>
> // Non-strict mode - accepts legacy blobs
> schema.$safeParse(data, { strict: false })
> ```

### Actions

Actions are composable functions that combine multiple XRPC calls into higher-level operations. They can be invoked using `client.call()` just like Lexicon methods, making them a powerful tool for building library-style APIs on top of the low-level client.

#### What are Actions?

An `Action` is a function with this signature:

```typescript
type Action<Input, Output> = (
  client: Client,
  input: Input,
  options: ActionOptions,
) => Output | Promise<Output>
```

Actions receive:

- `client` - The Client instance (to make XRPC calls)
- `input` - The input data for the action
- `options` - `ActionOptions` (currently just `{ signal?: AbortSignal }`)

#### Using Actions

Actions are called using `client.call()`, the same method used for XRPC queries and procedures:

```typescript
import { Action, Client, l } from '@atproto/lex'
import * as app from './lexicons/app.js'

// Define an action
export const likePost: Action<
  { uri: string; cid: string },
  { uri: string; cid: string }
> = async (client, { uri, cid }, options) => {
  client.assertAuthenticated()

  const result = await client.create(
    app.bsky.feed.like,
    {
      subject: { uri, cid },
      createdAt: l.currentDatetimeString(),
    },
    options,
  )

  return result
}

// Use the action
const client = new Client(session)
const like = await client.call(likePost, {
  uri: 'at://did:plc:abc/app.bsky.feed.post/123',
  cid: 'bafyreiabc...',
})
```

#### Composing Multiple Operations

Actions excel at combining multiple XRPC calls:

```typescript
import { Action, Client } from '@atproto/lex'
import * as app from './lexicons/app.js'

type Preference = app.bsky.actor.defs.Preferences[number]

// Action that reads, modifies, and writes preferences
const upsertPreference: Action<Preference, Preference[]> = async (
  client,
  newPref,
  options,
) => {
  // Read current preferences
  const { preferences } = await client.call(
    app.bsky.actor.getPreferences,
    options,
  )

  // Update the preference list
  const updated = [
    ...preferences.filter((p) => p.$type !== newPref.$type),
    newPref,
  ]

  // Save updated preferences
  await client.call(
    app.bsky.actor.putPreferences,
    { preferences: updated },
    options,
  )

  return updated
}

// Use it
await client.call(
  upsertPreference,
  app.bsky.actor.defs.adultContentPref.build({ enabled: true }),
)
```

#### Higher-Order Actions

Actions can call other actions, enabling powerful composition:

```typescript
import { Action } from '@atproto/lex'
import * as app from './lexicons/app.js'

type Preference = app.bsky.actor.defs.Preferences[number]

// Low-level action: update preferences with a function
const updatePreferences: Action<
  (prefs: Preference[]) => Preference[] | false,
  Preference[]
> = async (client, updateFn, options) => {
  const { preferences } = await client.call(
    app.bsky.actor.getPreferences,
    options,
  )

  const updated = updateFn(preferences)
  if (updated === false) return preferences

  await client.call(
    app.bsky.actor.putPreferences,
    { preferences: updated },
    options,
  )

  return updated
}

// Higher-level action: upsert a specific preference
const upsertPreference: Action<Preference, Preference[]> = async (
  client,
  pref,
  options,
) => {
  return updatePreferences(
    client,
    (prefs) => [...prefs.filter((p) => p.$type !== pref.$type), pref],
    options,
  )
}

// Even higher-level: enable adult content
const enableAdultContent: Action<void, Preference[]> = async (
  client,
  _,
  options,
) => {
  return upsertPreference(
    client,
    app.bsky.actor.defs.adultContentPref.build({ enabled: true }),
    options,
  )
}

// Use the high-level action
await client.call(enableAdultContent)
```

### Using a Single Client for Multiple Services

Because the client's `service` and `labelers` options are just _defaults_, a single `Client` instance can be used to talk to multiple services. This is the recommended pattern for SDK-style code: configure the client to talk to an AppView by default, and override `service` on a per-request basis when a request must reach another service — or the user's PDS directly (`service: null`).

```typescript
import { Client } from '@atproto/lex'
import * as app from './lexicons/app.js'
import * as com from './lexicons/com.js'

const client = new Client(session, {
  // All requests are proxied to the AppView by default
  service: 'did:web:api.bsky.app#bsky_appview',
})

// Proxied to the AppView (client default)
const { feed } = await client.call(app.bsky.feed.getTimeline)

// Sent directly to the user's PDS (no atproto-proxy header)
const { body } = await client.xrpc(com.atproto.repo.getRecord, {
  service: null,
  params: {
    repo: client.assertDid,
    collection: 'app.bsky.actor.profile',
    rkey: 'self',
  },
})

// Proxied to a different service, just for this request
await client.xrpc(app.bsky.actor.getProfile, {
  service: 'did:web:com.example.other#other_service',
  params: { actor: client.assertDid },
})
```

> [!NOTE]
>
> The record helpers (`create()`, `get()`, `put()`, `delete()`, `list()`, `createRecord()`, `getRecord()`, `putRecord()`, `deleteRecord()`, `listRecords()`, `applyWrites()`, `uploadBlob()`, `getBlob()`) always target the user's PDS: they default to `service: null` and `labelers: null`, ignoring the client's instance-wide defaults (unless explicitly overridden in their options).

If you need clients with different configurations sharing the same authentication, create them from the same session (or reuse an existing client's `agent`):

```typescript
const pdsClient = new Client(session)
const bskyClient = new Client(pdsClient.agent, {
  service: 'did:web:api.bsky.app#bsky_appview',
})
```

> [!NOTE]
>
> In previous version of this library, `Client` implemented the `Agent` interface, and its `fetchHandler` method could be used as the agent of another `Client`. This lead to confusing bugs ([#5110](https://github.com/bluesky-social/atproto/issues/5110)) where the inner agent (client) would set `atproto-proxy` and `atproto-accept-labelers` headers on requests where the outer client explicitly set `service: null` and `labelers: null`. This is no longer the case: a `Client` can no longer be used as the agent of another `Client`. To share authentication between differently-configured clients, build them from the same session.

### Building Library-Style APIs with Actions

Actions enable you to create high-level, convenience APIs similar to [@atproto/api](https://www.npmjs.com/package/@atproto/api)'s `Agent` class. Here are patterns for common operations:

#### Creating Posts

```typescript
import { Action, l } from '@atproto/lex'
import * as app from './lexicons/app.js'

type PostInput = Partial<app.bsky.feed.post.Main> &
  Omit<app.bsky.feed.post.Main, 'createdAt'>

export const post: Action<PostInput, { uri: string; cid: string }> = async (
  client,
  record,
  options,
) => {
  return client.create(
    app.bsky.feed.post,
    {
      ...record,
      createdAt: record.createdAt || l.currentDatetimeString(),
    },
    options,
  )
}

// Usage
await client.call(post, {
  text: 'Hello, AT Protocol!',
  langs: ['en'],
})
```

#### Following Users

```typescript
import { Action, l } from '@atproto/lex'
import { AtUri } from '@atproto/syntax'
import * as app from './lexicons/app.js'

export const follow: Action<
  { did: string },
  { uri: string; cid: string }
> = async (client, { did }, options) => {
  return client.create(
    app.bsky.graph.follow,
    {
      subject: did,
      createdAt: l.currentDatetimeString(),
    },
    options,
  )
}

export const unfollow: Action<{ followUri: string }, void> = async (
  client,
  { followUri },
  options,
) => {
  const uri = new AtUri(followUri)
  await client.delete(app.bsky.graph.follow, {
    ...options,
    rkey: uri.rkey,
  })
}

// Usage
const { uri } = await client.call(follow, { did: 'did:plc:abc123' })
await client.call(unfollow, { followUri: uri })
```

#### Updating Profile with Retry Logic

```
/* Detailed source-code truncated for AI context efficiency. */
```

#### Packaging Actions as a Library

Create a collection of actions for your application:

```typescript
// actions.ts
import { Action, Client } from '@atproto/lex'
import * as app from './lexicons/app.js'

export const post: Action</* ... */> = async (client, input, options) => {
  /* ... */
}
export const like: Action</* ... */> = async (client, input, options) => {
  /* ... */
}
export const follow: Action</* ... */> = async (client, input, options) => {
  /* ... */
}
export const updateProfile: Action</* ... */> = async (
  client,
  input,
  options,
) => {
  /* ... */
}
```

Usage:

```typescript
import * as actions from './actions.js'

await client.call(actions.post, { text: 'Hello!' })
```

#### Best Practices for Actions

1. **Type Safety**: Always provide explicit type parameters for `Action<Input, Output>`
2. **Authentication**: Use `client.assertAuthenticated()` when auth is required
3. **Abort Signals**: Check `options.signal?.throwIfAborted()` between long operations
4. **Composition**: Build complex actions from simpler ones
5. **Retries**: Implement retry logic for operations with optimistic concurrency control
6. **Tree-shaking**: Export actions individually to allow tree-shaking (instead of bundling them in a single class)

### Standard Schema Compatibility

All generated schemas implement the [Standard Schema](https://standardschema.dev/) interface (`StandardSchemaV1`), which means they can be used with any library or framework that supports Standard Schema, such as form validation libraries, API frameworks, and more.

Every `Schema` instance exposes a `~standard` property conforming to the spec:

```typescript
import * as app from './lexicons/app.js'

// Use with any Standard Schema-compatible library
const schema = app.bsky.feed.post

schema['~standard'].version // 1
schema['~standard'].vendor // '@atproto/lex-schema'

// Validate using the Standard Schema interface
const result = schema['~standard'].validate(someData)

if ('value' in result) {
  console.log(result.value) // Parsed and validated data
} else {
  console.error(result.issues)
}
```

When validated through the Standard Schema interface, schemas operate in "parse" mode, meaning transformations like defaults and coercions are applied to the output.

### Validating Generic Schemas with `$check`

`$check(data)` is the non-narrowing counterpart to [`$assert(data)`](#assertdata---type-narrowing-assertion): both throw when `data` does not match the schema, but `$check` does not refine the static type of its argument.

`$check` is rarely needed in application code — prefer `$assert`. It is intended for library-style code that takes a schema as a generic parameter, where TypeScript cannot satisfy the assertion-signature requirement and `$assert` produces the following error:

> 'schema' needs an explicit type annotation.
> Assertions require every name in the call target to be declared with an explicit type annotation. `ts(2775)`

In that situation, switch to `$check`:

```typescript
import type { Schema } from '@atproto/lex'

function ensureMatches<S extends Schema>(schema: S, data: unknown) {
  // schema.$assert(data) // ❌ ts(2775): needs an explicit type annotation
  schema.$check(data) // ✅ throws on invalid, no type narrowing
}
```

## License

MIT or Apache2

---

### Overview (`packages/oauth/oauth-client-browser/README.md`)

# atproto OAuth Client for the Browser

This package provides a browser specific OAuth client implementation for
atproto. It implements all the OAuth features required by [ATPROTO] (PKCE, DPoP,
etc.).

`@atproto/oauth-client-browser` is designed for front-end applications that do
not have a backend server to manage OAuth sessions, a.k.a "Single Page
Applications" (SPA).

> [!IMPORTANT]
>
> When a backend server is available, it is recommended to use
> [`@atproto/oauth-client-node`](https://www.npmjs.com/package/@atproto/oauth-client-node)
> to manage OAuth sessions from the server side and use a session cookie to map
> the OAuth session to the front-end. Because this mechanism allows the backend
> to invalidate OAuth credentials at scale, this method is more secure than
> managing OAuth sessions from the front-end directly. Thanks to the added
> security, the OAuth server will provide longer lived tokens when issued to a
> BFF (Backend-for-frontend).

## Setup

### Client ID

The `client_id` is what identifies your application to the OAuth server. It is
used to fetch the client metadata and to initiate the OAuth flow. The
`client_id` must be a URL that points to the [client
metadata](#client-metadata).

### Client Metadata

Your OAuth client metadata should be hosted at a URL that corresponds to the
`client_id` of your application. This URL should return a JSON object with the
client metadata. The client metadata should be configured according to the
needs of your application and must respect the [ATPROTO] spec.

```json
{
  // Must be the same URL as the one used to obtain this JSON object
  "client_id": "https://my-app.com/client-metadata.json",
  "client_name": "My App",
  "client_uri": "https://my-app.com",
  "logo_uri": "https://my-app.com/logo.png",
  "tos_uri": "https://my-app.com/tos",
  "policy_uri": "https://my-app.com/policy",
  "redirect_uris": ["https://my-app.com/callback"],
  "scope": "atproto",
  "grant_types": ["authorization_code", "refresh_token"],
  "response_types": ["code"],
  "token_endpoint_auth_method": "none",
  "application_type": "web",
  "dpop_bound_access_tokens": true
}
```

The client metadata is used to instantiate an OAuth client. There are two ways
of doing this:

1. Either you "burn" the metadata into your application:

   ```typescript
   import { BrowserOAuthClient } from '@atproto/oauth-client-browser'

   const client = new BrowserOAuthClient({
     clientMetadata: {
       // Exact same JSON object as the one returned by the client_id URL
     },
     // ...
   })
   ```

2. Or you load it asynchronously from the URL:

   ```typescript
   import { OAuthClient } from '@atproto/oauth-client-browser'

   const client = await BrowserOAuthClient.load({
     clientId: 'https://my-app.com/client-metadata.json',
     // ...
   })
   ```

If performances are important to you, it is recommended to burn the metadata
into the script. Server side rendering techniques can also be used to inject the
metadata into the script at runtime.

### Handle Resolver

Whenever your application initiates an OAuth flow, it will start to resolve
the (user provider) APTROTO handle of the user. This is typically done though a
DNS request. However, because DNS resolution is not available in the browser, a
backend service must be provided.

> [!CAUTION]
>
> Using Bluesky-hosted services for handle resolution (eg, the `bsky.social`
> endpoint) will leak both user IP addresses and handle identifiers to Bluesky,
> a third party. While Bluesky has a declared privacy policy, both developers
> and users of applications need to be informed and aware of the privacy
> implications of this arrangement. Application developers are encouraged to
> improve user privacy by operating their own handle resolution service when
> possible. If you are a PDS self-hoster, you can use your PDS's URL for
> `handleResolver`.

If a `string` or `URL` object is used as `handleResolver`, the library will
expect this value to be the URL of a service running the
`com.atproto.identity.resolveHandle` XRPC Lexicon method.

> [!TIP]
>
> If you host your own PDS, you can use its URL as a handle resolver.

```typescript
import { BrowserOAuthClient } from '@atproto/oauth-client-browser'

const client = new BrowserOAuthClient({
  handleResolver: 'https://my-pds.example.com',
  // ...
})
```

Alternatively, if a "DNS over HTTPS" (DoH) service is available, it can be used
to resolve the handle. In this case, the `handleResolver` should be initialized
with a `AtprotoDohHandleResolver` instance:

```typescript
import {
  BrowserOAuthClient,
  AtprotoDohHandleResolver,
} from '@atproto/oauth-client-browser'

const client = new BrowserOAuthClient({
  handleResolver: new AtprotoDohHandleResolver('https://my-doh.example.com'),
  // ...
})
```

### Other configuration options

In addition to [Client Metadata](#client-metadata) and [Handle
Resolver](#handle-resolver), the `BrowserOAuthClient` constructor accepts the
following optional configuration options:

- `fetch`: A custom wrapper around the `fetch` function. This can be useful to
  add custom headers, logging, or to use a different fetch implementation.
  Defaults to `window.fetch`.

- `responseMode`: `query` or `fragment`. Determines how the authorization
  response is returned to the client. Defaults to `fragment`.

- `plcDirectoryUrl`: The URL of the PLC directory. This will typically not be
  needed unless you run an entire atproto stack locally. Defaults to
  `https://plc.directory`.

## Usage

Once the `client` is set up, it can be used to initiate & manage OAuth sessions.

### Initializing the client

The client will manage the sessions for you. In order to do so, it must first
initialize itself. Note that this operation must be performed once (and **only
once**) whenever the web app is loaded.

```typescript
const result: undefined | { session: OAuthSession; state?: string } =
  await client.init()

if (result) {
  const { session, state } = result
  if (state != null) {
    console.log(
      `${session.sub} was successfully authenticated (state: ${state})`,
    )
  } else {
    console.log(`${session.sub} was restored (last active session)`)
  }
}
```

The return value can be used to determine if the client was able to restore the
last used session (`session` is defined) or if the current navigation is the
result of an authorization redirect (both `session` and `state` are defined).

### Initiating an OAuth flow

In order to initiate an OAuth flow, we must first determine which PDS the
authentication flow will be initiated from. This means that the user must
provide one of the following information:

- The user's handle
- The user's DID
- A PDS/Entryway URL

Using that information, the OAuthClient will resolve all the needed information
to initiate the OAuth flow, and redirect the user to the OAuth server.

```typescript
try {
  await client.signIn('my.handle.com', {
    state: 'some value needed later',
    prompt: 'none', // Attempt to sign in without user interaction (SSO)
    ui_locales: 'fr-CA fr en', // Only supported by some OAuth servers (requires OpenID Connect support + i18n support)
    signal: new AbortController().signal, // Optional, allows to cancel the sign in (and destroy the pending authorization, for better security)
  })

  console.log('Never executed')
} catch (err) {
  console.log('The user aborted the authorization process by navigating "back"')
}
```

The returned promise will never resolve (because the user will be redirected to
the OAuth server). The promise will reject if the user cancels the sign in
(using an `AbortSignal`), or if the user navigates back from the OAuth server
(because of browser's back-forward cache).

### Handling the OAuth response

When the user is redirected back to the application, the OAuth response will be
available in the URL. The `BrowserOAuthClient` will automatically detect the
response and handle it when `client.init()` is called. Alternatively, the
application can manually handle the response using the
`client.callback(urlQueryParams)` method.

### Restoring a session

The client keeps track of all the sessions that it manages through an internal
store. Regardless of the session that was returned from the `client.init()`
call, any other session can be loaded using the `client.restore()` method. This
method will throw an error if the session is no longer available or if it has
become expired.

```ts
const aliceSession = await client.restore('did:plc:alice')
const bobSession = await client.restore('did:plc:bob')
```

In its current form, the client does not expose methods to list all sessions
in its store. The app will have to keep track of those itself.

### Watching for session invalidation

The client will emit events whenever a session becomes unavailable, allowing to
trigger global behaviors (e.g. show the login page).

```ts
client.addEventListener(
  'deleted',
  (
    event: CustomEvent<{
      sub: string
      cause: TokenRefreshError | TokenRevokedError | TokenInvalidError
    }>,
  ) => {
    const { sub, cause } = event.detail
    console.error(`Session for ${sub} is no longer available (cause: ${cause})`)
  },
)
```

## Usage with `@atproto/api`

The `@atproto/api` package provides a way to interact with multiple Bluesky
specific XRPC lexicons (`com.atproto`, `app.bsky`, `chat.bsky`, `tools.ozone`)
through the `Agent` interface. The `oauthSession` returned by the
`BrowserOAuthClient` can be used to instantiate an `Agent` instance.

```typescript
import { Agent } from '@atproto/api'

const session = await client.restore('did:plc:alice')

const agent = new Agent(session)

await agent.getProfile({ actor: agent.accountDid })
```

Any refresh of the credentials will happen under the hood, and the new tokens
will be saved in the session store (in the browser's indexed DB).

## Advances use-cases

### Using in development (localhost)

The OAuth server must be able to fetch the `client_metadata` object. The best
way to do this if you didn't already deployed your app is to use a tunneling
service like [ngrok](https://ngrok.com/).

The `client_id` will then be something like
`https://<your-ngrok-id>.ngrok.io/<path_to_your_client_metadata>`.

There is however a special case for loopback clients. A loopback client is a
client that runs on `localhost`. In this case, the OAuth server will not be able
to fetch the `client_metadata` object because `localhost` is not accessible from
the outside. To work around this, atproto OAuth servers are required to support
this case by providing an hard coded `client_metadata` object for the client.

This has several restrictions:

1. There is no way of configuring the client metadata (name, logo, etc.)
2. The validity of the refresh tokens (if any) will be very limited (typically 1
   day)
3. Silent-sign-in will not be allowed
4. Only `http://127.0.0.1:<any_port>` and `http://[::1]:<any_port>` can be used
   as origin for your app, and **not** `http://localhost:<any_port>`. This
   library will automatically redirect the user to an IP based origin
   (`http://127.0.0.1:<port>`) when visiting an origin with `localhost`.

Using a loopback client is only recommended for development purposes. A loopback
client can be instantiated like this:

```typescript
import { BrowserOAuthClient } from '@atproto/oauth-client-browser'

const client = new BrowserOAuthClient({
  handleResolver: 'https://bsky.social',
  // Only works if the current origin is a loopback address:
  clientMetadata: undefined,
})
```

If you need to use a special `redirect_uris`, you can configure them like this:

```typescript
import { BrowserOAuthClient } from '@atproto/oauth-client-browser'

const client = new BrowserOAuthClient({
  handleResolver: 'https://bsky.social',
  // Note that the origin of the "client_id" URL must be "http://localhost" when
  // using this configuration, regardless of the actual hostname ("127.0.0.1" or
  // "[::1]"), port or pathname. Only the `redirect_uris` must contain the
  // actual url that will be used to redirect the user back to the application.
  clientMetadata: `http://localhost?redirect_uri=${encodeURIComponent('http://127.0.0.1:8080/callback')}`,
})
```

[ATPROTO]: https://atproto.com/ 'AT Protocol'

---

### Overview (`packages/oauth/oauth-client-expo/README.md`)

# Expo Atproto OAuth

This is an Expo client library for Atproto OAuth. It implements the required
native crypto functions for supporting JWTs in React Native and uses the base
`OAuthClient` interface found in [the Atproto repository](https://github.com/bluesky-social/atproto/tree/main/packages/oauth/oauth-client).

### In bare React Native projects

For bare React Native projects, you must ensure that you have [installed and configured the `expo` package](https://docs.expo.dev/bare/installing-expo-modules/)
before continuing.

## Installation

Once you have satisfied the prerequisites, you can simply install the library with `npm install --save @atproto/oauth-client-expo`.

## Usage

### Serve your `oauth-client-metadata.json`

You will need to server an `oauth-client-metadata.json` from your application's website. An example of this metadata
would look like this:

```json
// assets/oauth-client-metadata.json
{
  "client_id": "https://example.com/oauth-client-metadata.json",
  "client_name": "React Native OAuth Client Demo",
  "client_uri": "https://example.com",
  "redirect_uris": ["com.example:/auth/callback"],
  "scope": "atproto repo:* rpc:*?aud=did:web:api.bsky.app#bsky_appview",
  "token_endpoint_auth_method": "none",
  "response_types": ["code"],
  "grant_types": ["authorization_code", "refresh_token"],
  "application_type": "native",
  "dpop_bound_access_tokens": true
}
```

- The `client_id` should be the same URL as where you are serving your
  `oauth-client-metadata.json` from
- The `client_uri` can be the home page of where you are serving your metadata
  from
- Your `redirect_uris` should contain a native redirect URI (for ios/android),
  as well as a web redirect URI (for web).
- native redirect URI must have a custom scheme, which is formatted as the
  _reverse_ of the domain you are serving the metadata from. Since I am serving
  mine from `example.com`, I use `com.example` as the scheme. If my domain were
  `atproto.expo.dev`, I would use `dev.expo.atproto`. Additionally, the scheme
  _must_ contain _only one trailing slash_ after the `:`. `com.example://` is
  invalid.
- The `application_type` must be `native`

For a real-world example, see [Skylight's client metadata](https://skylight.expo.app/oauth/client-metadata.json).

For more information about client metadata, see [the Atproto documentation](https://atproto.com/specs/oauth#client-id-metadata-document).

### Create a client

Next, you want to create an `ExpoOAuthClient`. You will need to pass in the same client metadata to the client as you are serving in your `oauth-client-metadata.json`.

```ts
// utils/oauth-client.ts
const clientMetadata = require('../assets/oauth-client-metadata.json')

const client = new ExpoOAuthClient({
  handleResolver: 'https://bsky.social',
  clientMetadata,
})
```

### Sign a user in

Whenever you are ready, you can initiate a sign in attempt for the user using the client using `client.signIn(input)`

`input` must be one of the following:

- A valid Atproto user handle, e.g. `hailey.bsky.team` or `example.com`
- A valid DID, e.g. `did:web:example.com` or `did:plc:oisofpd7lj26yvgiivf3lxsi`
- A valid PDS host, e.g. `https://cocoon.example.com` or `https://bsky.social`

> [!NOTE] If you wish to allow a user to _create_ an account instead of signing
> in, simply use a valid PDS hostname rather than a handle. They will be
> presented the option to either Sign In with an existing account, or create a
> new one, on the PDS's sign in page.

The response of `signIn` will be a promise resolving to the following:

```ts
    | { status: WebBrowserResultType } // See Expo Web Browser documentation
    | { status: 'error'; error: unknown }
    | { status: 'success'; session: OAuthSession }
```

For example:

```ts
try {
  const session = await client.signIn(input ?? '')
  setSession(session)
  const agent = new Agent(session)
  setAgent(agent)
} catch (err) {
  Alert.alert('Error', String(err))
}
```

### Create an `Agent`

To interface with the various Atproto APIs, you will need to create an `Agent`. You will pass your `OAuthSession` to the `Agent` or `XrpcClient` constructor.

```ts
const agent = new Agent(session)
// or
const xrpc = new XrpcClient(session)
```

Session refreshes will be handled for you for the lifetime of the agent.

### Restoring a session

After, for example, closing the application, you will probably need to restore the user's session. You can do this by using the user's DID on the `ExpoOAuthClient`.

```ts
const session = await client.restore('did:plc:oisofpd7lj26yvgiivf3lxsi')
const agent = new Agent(session)
```

If the session needs to be refreshed, `.restore()` will automatically do this for you before returning a session (based on the token's expiration date). In order to force a refresh, you can pass in `true` as the second argument to `restore`.

```ts
const session = await client.restore(
  'did:plc:oisofpd7lj26yvgiivf3lxsi',
  true, // force a refresh, ensuring tokens were not revoked
)
```

## Additional Reading

- [Atproto OAuth Spec](https://atproto.com/specs/oauth)
- [Atproto Web OAuth Example](https://github.com/bluesky-social/atproto/tree/main/packages/oauth/oauth-client-browser-example)

---

### Overview (`packages/oauth/oauth-client-node/README.md`)

# atproto OAuth Client for NodeJS

This package implements all the OAuth features required by [ATPROTO] (PKCE,
etc.) to run in a NodeJS based environment such as desktop apps built with
Electron or traditional web app backends built with frameworks like Express.

## Setup

### Client configuration

The `client_id` is what identifies your application to the OAuth server. It is
used to fetch the client metadata, and to initiate the OAuth flow. The
`client_id` must be a URL that points to the client metadata.

Your OAuth client metadata should be hosted at a URL that corresponds to the
`client_id` of your application. This URL should return a JSON object with the
client metadata. The client metadata should be configured according to the
needs of your application, and must respect the [ATPROTO].

#### From a backend service

The `client_metadata` object will typically be built by the backend at startup.

```
/* Detailed source-code truncated for AI context efficiency. */
```

#### From a native application

This applies to mobile apps, desktop apps, etc. based on NodeJS (e.g. Electron).

The client metadata must be hosted on an internet-accessible URL owned by you.
The client metadata will typically contain:

```json
{
  "client_id": "https://my-app.com/client-metadata.json",
  "client_name": "My App",
  "client_uri": "https://my-app.com",
  "logo_uri": "https://my-app.com/logo.png",
  "tos_uri": "https://my-app.com/tos",
  "policy_uri": "https://my-app.com/policy",
  "redirect_uris": ["https://my-app.com/atproto-oauth-callback"],
  "scope": "atproto",
  "grant_types": ["authorization_code", "refresh_token"],
  "response_types": ["code"],
  "application_type": "native",
  "token_endpoint_auth_method": "none",
  "dpop_bound_access_tokens": true
}
```

Instead of hard-coding the client metadata in your app, you can fetch it when
the app starts:

```ts
import { NodeOAuthClient } from '@atproto/oauth-client-node'

const client = await NodeOAuthClient.fromClientId({
  clientId: 'https://my-app.com/client-metadata.json',

  stateStore: {
    async set(key: string, internalState: NodeSavedState): Promise<void> {},
    async get(key: string): Promise<NodeSavedState | undefined> {},
    async del(key: string): Promise<void> {},
  },

  sessionStore: {
    async set(sub: string, session: Session): Promise<void> {},
    async get(sub: string): Promise<Session | undefined> {},
    async del(sub: string): Promise<void> {},
  },

  // A lock to prevent concurrent access to the session store. Optional if only one instance is running.
  requestLock,
})
```

> [!NOTE]
>
> There is no `keyset` in this instance. This is due to the fact that app
> clients cannot safely store a private key. The `token_endpoint_auth_method` is
> set to `none` in the client metadata, which means that the client will not be
> authenticating itself to the token endpoint. This will cause sessions to have
> a shorter lifetime. You can circumvent this by providing a "BFF" (Backend for
> Frontend) that will perform an authenticated OAuth flow and use a session id
> based mechanism to authenticate the client.

### Common configuration options

The `OAuthClient` and `OAuthAgent` classes will manage and refresh OAuth tokens
transparently. They are also responsible to properly format the HTTP requests
payload, using DPoP, and transparently retrying requests when the access token
expires.

For this to work, the client must be configured with the following options:

#### `sessionStore`

A simple key-value store to save the OAuth session data. This is used to save
the access token, refresh token, and other session data.

```ts
const sessionStore: NodeSavedSessionStore = {
  async set(sub: string, sessionData: NodeSavedSession) {
    // Insert or update the session data in your database
    await saveSessionDataToDb(sub, sessionData)
  },

  async get(sub: string) {
    // Retrieve the session data from your database
    const sessionData = await getSessionDataFromDb(sub)
    if (!sessionData) return undefined

    return sessionData
  },

  async del(sub: string) {
    // Delete the session data from your database
    await deleteSessionDataFromDb(sub)
  },
}
```

#### `stateStore`

A simple key-value store to save the state of the OAuth
authorization flow. This is used to prevent CSRF attacks.

The implementation of the `StateStore` is similar to the
[`sessionStore`](#sessionstore).

```ts
interface NodeSavedStateStore {
  set: (key: string, internalState: NodeSavedState) => Promise<void>
  get: (key: string) => Promise<NodeSavedState | undefined>
  del: (key: string) => Promise<void>
}
```

One notable exception is that state store items can (and should) be deleted
after a short period of time (one hour should be more than enough).

#### `requestLock`

When multiple instances of the client are running, this lock will prevent
concurrent refreshes of the same session. If the lock fails to be acquired an
error should be thrown.

Here is an example implementation based on [`redlock`](https://www.npmjs.com/package/redlock):

```ts
import { RuntimeLock } from '@atproto/oauth-client-node'
import Redis from 'ioredis'
import Redlock from 'redlock'

const redisClients = new Redis()
const redlock = new Redlock(redisClients)

const requestLock: RuntimeLock = async (key, fn) => {
  // 30 seconds should be enough. Since we will be using one lock per user id
  // we can be quite liberal with the lock duration here.
  const lock = await redlock.lock(key, 45e3)
  try {
    return await fn()
  } finally {
    await redlock.unlock(lock)
  }
}
```

## Usage with `@atproto/api`

`@atproto/oauth-client-*` packages all return an `ApiClient` instance upon
successful authentication. This instance can be used to make authenticated
requests using all the `ApiClient` methods defined in [[API]] (non exhaustive
list of examples below). Any refresh of the credentials will happen under the
hood, and the new tokens will be saved in the session store.

```
/* Detailed source-code truncated for AI context efficiency. */
```

## Advances use-cases

### Listening for session updates and deletion

The `OAuthClient` will emit events whenever a session is updated or deleted.

```ts
import {
  Session,
  TokenRefreshError,
  TokenRevokedError,
} from '@atproto/oauth-client-node'

client.addEventListener('updated', (event: CustomEvent<Session>) => {
  console.log('Refreshed tokens were saved in the store:', event.detail)
})

client.addEventListener(
  'deleted',
  (
    event: CustomEvent<{
      sub: string
      cause: TokenRefreshError | TokenRevokedError | unknown
    }>,
  ) => {
    console.log('Session was deleted from the session store:', event.detail)

    const { cause } = event.detail

    if (cause instanceof TokenRefreshError) {
      // - refresh_token unavailable or expired
      // - oauth response error (`cause.cause instanceof OAuthResponseError`)
      // - session data does not match expected values returned by the OAuth server
    } else if (cause instanceof TokenRevokedError) {
      // Session was revoked through:
      // - session.signOut()
      // - client.revoke(sub)
    } else {
      // An unexpected error occurred, causing the session to be deleted
    }
  },
)
```

### Silent Sign-In

Using silent sign-in requires to handle retries on the callback endpoint.

```
/* Detailed source-code truncated for AI context efficiency. */
```

[ATPROTO]: https://atproto.com/ 'AT Protocol'
[API]: ../../api/README.md

---

### Overview (`packages/oauth/oauth-client/README.md`)

# @atproto/oauth-client: atproto flavoured OAuth client

Core library for implementing [atproto][ATPROTO] OAuth clients.

For a browser specific implementation, see [@atproto/oauth-client-browser](https://www.npmjs.com/package/@atproto/oauth-client-browser).
For a node specific implementation, see
[@atproto/oauth-client-node](https://www.npmjs.com/package/@atproto/oauth-client-node).

## Usage

### Configuration

```
/* Detailed source-code truncated for AI context efficiency. */
```

### Authentication

```ts
const url = await client.authorize('foo.bsky.team', {
  state: '434321',
  prompt: 'consent',
  scope: 'email',
  ui_locales: 'fr',
})
```

Make user visit `url`. Then, once it was redirected to the callback URI, perform the following:

```ts
// Parse the query params from the callback URI
const params = new URLSearchParams('code=...&state=...')

// Process the callback using the OAuth client
const result = await client.callback(params)

// Verify the state (e.g. to link to an internal user)
result.state === '434321' // true

const oauthSession = result.session
```

The sign-in process results in an `OAuthSession` instance that can be used to make
authenticated requests to the resource server. This instance will automatically
refresh the credentials when needed.

### Making authenticated requests

The `OAuthSession` instance obtained after signing in can be used to make
authenticated requests to the user's PDS. There are two main use-cases:

1. Making authenticated request to Bluesky's AppView in order to fetch and
   manipulate data from the `app.bsky` lexicon.

2. Making authenticated request to your own AppView, in order to fetch and
   manipulate data from your own lexicon.

#### Making authenticated requests to Bluesky's AppView

The `@atproto/oauth-client` package provides a `OAuthSession` class that can be
used to make authenticated requests to Bluesky's AppView. This can be achieved
by constructing an `Agent` (from `@atproto/api`) instance using the
`OAuthSession` instance.

```ts
import { Agent } from '@atproto/api'

const agent = new Agent(oauthSession)

// Make an authenticated request to the server. New credentials will be
// automatically fetched if needed (causing sessionStore.set() to be called).
await agent.post({
  text: 'Hello, world!',
})

// revoke credentials on the server (causing sessionStore.del() to be called)
await agent.signOut()
```

#### Making authenticated requests to your own AppView

The `OAuthSession` instance obtained after signing in can be used to instantiate
the `XrpcClient` class from the `@atproto/xrpc` package.

```ts
import { Lexicons } from '@atproto/lexicon'
import { OAuthClient } from '@atproto/oauth-client' // or "@atproto/oauth-client-browser" or "@atproto/oauth-client-node"
import { XrpcClient } from '@atproto/xrpc'

// Define your lexicons
const myLexicon = new Lexicons([
  {
    lexicon: 1,
    id: 'com.example.query',
    defs: {
      main: {
        // ...
      },
    },
  },
])

// Describe your app's oauth client
const oauthClient = new OAuthClient({
  // ...
})

// Authenticate the user
const oauthSession = await oauthClient.restore('did:plc:123')

// Instantiate a client using the `oauthSession` as fetch handler object
const client = new XrpcClient(oauthSession, myLexicon)

// Make authenticated calls
const response = await client.call('com.example.query')
```

Note that the user's PDS might not know about your lexicon, or what to do with
those calls (PDS' are only mandated to implement the `com.atproto` lexicon). In
order to process your calls, you need to have a backend that will process those
calls. You can then instruct your PDS to forward those calls to your backend.

```ts
const response = await client.call(
  'com.example.query',
  {
    // Params
  },
  {
    headers: {
      // The PDS will proxy calls to the specified service in did:plc:xyz's did document.
      // These calls will be authenticated using "service auth", a single use JWT Bearer token, signed with the logged-in user's private key.
      'atproto-proxy': 'did:plc:xyz#serviceId',
    },
  },
)
```

You can also instantiate the `XrpcClient` class with a custom `fetch` function
that will provide the `atproto-proxy` header on all calls:

```ts
const boundClient = new XrpcClient((url, init) => {
  const headers = new Headers(init?.headers)

  // Add the atproto-proxy header if it is not already present
  if (!headers.has('atproto-proxy')) {
    headers.set('atproto-proxy', 'did:plc:xyz#serviceId')
  }

  return oauthSession.fetchHandler(url, { ...init, headers })
}, myLexicon)

// No need to specify the atproto-proxy header anymore
const response = await boundClient.call('com.example.query')
```

> [!NOTE]
>
> Proxying every call through the PDS is not recommended for performance
> reasons, as it will increase the latency of readonly calls to your lexicon.
> Doing so will also prevent your backend from being able to anticipate writes
> on the network. Indeed, write calls will be sent to the PDS, which will then
> propagate them on the network through a relay (a.k.a. "firehose"). This will
> introduce a delay between the time the write is made and the time it is
> processed by your backend.
>
> In order to avoid those issues, it is recommended that you implement your
> backend using a backend-for-frontend pattern. This backend will be responsible
> for processing the calls made by the client, and will be able to anticipate
> writes on the network.
>
> Read more about the backend-for-frontend pattern in the [atproto][ATPROTO]
> documentation website.

## Advances use-cases

### Listening for session updates and deletion

The `OAuthClient` will emit events whenever a session is updated or deleted.

```ts
import {
  Session,
  TokenRefreshError,
  TokenRevokedError,
} from '@atproto/oauth-client'

client.addEventListener('updated', (event: CustomEvent<Session>) => {
  console.log('Refreshed tokens were saved in the store:', event.detail)
})

client.addEventListener(
  'deleted',
  (
    event: CustomEvent<{
      sub: string
      cause: TokenRefreshError | TokenRevokedError | unknown
    }>,
  ) => {
    console.log('Session was deleted from the session store:', event.detail)

    const { cause } = event.detail

    if (cause instanceof TokenRefreshError) {
      // - refresh_token unavailable or expired
      // - oauth response error (`cause.cause instanceof OAuthResponseError`)
      // - session data does not match expected values returned by the OAuth server
    } else if (cause instanceof TokenRevokedError) {
      // Session was revoked through:
      // - agent.signOut()
      // - client.revoke(sub)
    } else {
      // An unexpected error occurred, causing the session to be deleted
    }
  },
)
```

### Force user to re-authenticate

```ts
const url = await client.authorize(handle, {
  prompt: 'login',
  state,
})
```

or

```ts
const url = await client.authorize(handle, {
  state,
})
```

### Silent Sign-In

Using silent sign-in requires to handle retries on the callback endpoint.

```ts
async function createLoginUrl(handle: string, state?: string): string {
  return client.authorize(handle, {
    state,
    // Use "prompt=none" to attempt silent sign-in
    prompt: 'none',
  })
}

async function handleCallback(params: URLSearchParams) {
  try {
    return await client.callback(params)
  } catch (err) {
    // Silent sign-in failed, retry without prompt=none
    if (
      err instanceof OAuthCallbackError &&
      ['login_required', 'consent_required'].includes(err.params.get('error'))
    ) {
      // Do *not* use prompt=none when retrying (to avoid infinite redirects)
      const url = await client.authorize(handle, { state: err.state })

      // Allow calling code to catch the error and redirect the user to the new URL
      return new MyLoginRequiredError(url)
    }

    throw err
  }
}
```

[ATPROTO]: https://atproto.com/ 'AT Protocol'

---

### Overview (`packages/oauth/oauth-provider-ui/README.md`)

# @atproto/oauth-provider-ui

The sign-in, sign-up, consent and account-management screens served by a PDS
during OAuth. React 19, TanStack Router, Tailwind 4, shadcn on Base UI, Lingui.

The design decisions behind these screens are in
[DesignPrinciples.md](./DesignPrinciples.md); the engineering constraints
are in [CLAUDE.md](./CLAUDE.md).

## Running the mock

```sh
pnpm install
pnpm run i18n:compile   # once, or after changing any string
pnpm dev:ui             # http://localhost:5174
```

Then open:

- `/authorization-page.html` — the OAuth flow: picker, sign-in, 2FA,
  password reset, sign-up, consent, redirect.
- `/account-page.html` — the account manager, every path under `/account`.
- `/error-page.html` and `/cookie-error-page.html`.

Everything runs against `src/mock-api.ts`; no PDS, no Docker. Sign in as any
listed account with any password. `matthieu.bsky.social` asks for a 2FA code
(`AAAAA-AAAAA`), `alice.test` is deactivated.

## Checking a change

```sh
pnpm exec tsc --build tsconfig.json
pnpm test
pnpm run i18n          # then check the msgid diff and fill every locale
```

Run eslint from the repository root, not from this package.

---

### Overview (`packages/oauth/oauth-types/README.md`)

# @atproto/oauth-types

This library exposes utilities for typing and validating OAuth related data structures.

---

### Overview (`packages/bsky/src/feature-gates/README.md`)

# Feature Gates

A thin wrapper around [GrowthBook](https://www.growthbook.io/) for feature flag
evaluation and experiment tracking.

## Usage

Feature gates are accessible on `HydrationCtx` as `features`. A default "scope"
is defined in `Hydrator.createContext`, which is called by every request
handler. The default scope supplies anonymous `deviceId` and `sessionId`
identifiers for targeting unauthenticated users.

Feature gates can be checked via the following methods.

```typescript
// check a single gate
const enabled = ctx.features.checkGate(
  ctx.features.Gate.ThreadsReplyRankingExplorationEnable,
)

// check multiple gates
const gates = ctx.features.checkGates([
  ctx.features.Gate.ThreadsReplyRankingExplorationEnable,
  ctx.features.Gate.SomeOtherGate,
])
const enabled = gates.get(
  ctx.features.Gate.ThreadsReplyRankingExplorationEnable,
)
```

### User Context

To accurately gate features by user, we need additional context about that user.
For most use cases, we get this data from within the request handlers.

Here, the `features` client will be scoped to the current user, allowing for
accurate gate checks throughout the request lifecycle.

```typescript
const features: ScopedFeatureGatesClient = ctx.featureGatesClient.scope(
  ctx.featureGatesClient.parseUserContextFromHandler({
    viewer,
    req,
  }),
)

const hydrateCtx = await ctx.hydrator.createContext({
  labelers,
  viewer,
  features,
})
```

> [!NOTE]
> Although `ctx.featureGatesClient` also has a `checkGates` method, the
> intention is to use the `ScopedFeatureGatesClient` returned by `scope()` for
> gate checks within the application.

You can also pass the user context directly to `checkGate` or `checkGates`. This
overrides any default scope set on the client, allowing for flexibility in cases
where user context is only available at the point of gate evaluation.

```typescript
const enabled = ctx.featureGatesClient.checkGate(
  ctx.featureGatesClient.Gate.ImageFeatureEnabled,
  { did: imageAuthor.did },
)
```

### Adding Gates

See `gates.ts` and add them to the enum. You can optionally prevent metrics from
being emitted for a gate by adding the gate to the `IGNORE_METRICS_FOR_GATES`
set.

## Metrics

Check out the Growthbook docs for more info here. Basically, every feature eval
fires the `onFeatureUsage` callback. Any feature that is part of an experiment
_will also fire the `trackingCallback` callback._

For some use cases, this can be noisy and/or performance intensive, so make use
of `IGNORE_METRICS_FOR_GATES` to silence metrics for specific gates.

---

### Overview (`packages/pds/src/scripts/README.md`)

# PDS Scripts

This directory includes some low-level administrative scripts primarily meant to help recover from situations of data loss or repository corruption.

These scripts are included in the Docker image. The recommended way to run them is to shell into the PDS container (`docker exec -it pds /bin/sh`) and run the relevant script using `node run-script.js SCRIPT_NAME`.

### rebuild-repo

Rebuild a repo's MST and sign a new commit based on data stored in the actor's record table. Intended to be used if a repository is corrupted and there are missing MST blocks.

`node run-script.js rebuild-repo DID`

### publish-identity

Publishes an identity event on the PDS's outgoing firehose for the relevant DID. Intended to be used if a user's identity is out of date and a refresh of their identity needs to be pushed through the system.

`node run-script.js publish-identity DID`
`node run-script.js publish-identity DID1 DID2 DID3 ...`
`node run-script.js publish-identity-file dids.txt` (where `dids.txt` is a `\n` delimited text file of dids)

### rotate-keys

Ensures that an account's signing key in their PLC DID document matches the signing key that the PDS is holidng for them locally. If not, then update their PLC document. Does not work for `did:web`s. Intended to be used in recovery situations where an accounts' signing key is lost and it needs to be re-generated. This script _does not_ regenerate the key.

`node run-script.js rotate-keys DID`
`node run-script.js rotate-keys DID1 DID2 DID3 ...`
`node run-script.js rotate-keys-file dids.txt` (where `dids.txt` is a `\n` delimited text file of dids)
`node run-script.js rotate-keys-recovery` (to be used after `sequencer-recovery` script)

### sequencer-recovery

Replays the sequencer file on top of actor stores. Creates new actor stores & keys for actors that do not exist but are in the sequencer. Deletes actor stores & entries in the accounts DB when processing an account deletion on the stream. This script is meant to be re-runnable. Though because it processes events in parallel, it is important to be discerning about the cursor you pick up from if you stop & start the script.

Does _not_ rotate signing keys even if it generates them. Signing keys that need to be rotated are stored in a recovery DB and can be actually rotated with the `rotate-keys-recovery` script.

Intended to be used for recovery from data loss.

`node run-script.js sequencer-recovery START_CURSOR CONCURRENCY` (both params are optional & default to `0` and `10` respectively)

Failures are also tracked in the recovery DB and can be recovered from with `node run-script.js recovery-repair-repos`. Which will rebuild repos (a la `rebuild-repo`) and then play back the events from the sequencer only pertaining to the recovered DIDs.

---

### Overview (`packages/oauth/oauth-client-browser/src/indexed-db/README.md`)

# IndexedDB utilities

This is a small wrapper around the IndexedDB API that provides a simple way to
store and retrieve data from an IndexedDB database.

This _could_ be used as a standalone library, but the Bluesky dev team does not
want to maintain it as such. As it is currently only used by the
`@atproto/oauth-client-browser` package, it is included here.

---

### Overview (`packages/oauth/oauth-provider/src/lib/html/README.md`)

# Safe HTML generation and concatenation utility

This library provides a safe way to generate and concatenate HTML strings.

This code _could_ be used as a standalone library, but the Bluesky dev team does
not want to maintain it as such. As it is currently only used by the
`@atproto/oauth-provider` package, it is included here. Future development
should aim to keep this library independent of the rest of the
`@atproto/oauth-provider` package, so that it can be extracted and published.

---

### Overview (`packages/oauth/oauth-provider/src/lib/http/README.md`)

# utilities for generating middlewares to work with Node's http module or Express / Connect frameworks

This library uses a functional programming style to generate middleware
functions that can be used with Node's http module or Express / Connect
frameworks.

This code _could_ be used as a standalone library, but the Bluesky dev team does
not want to maintain it as such. As it is currently only used by the
`@atproto/oauth-provider` package, it is included here. Future development
should aim to keep this library independent of the rest of the
`@atproto/oauth-provider` package, so that it can be extracted and published.

---

### Packages Api Docs Moderation (`packages/api/docs/moderation.md`)

# Moderation API

Applying the moderation system is a challenging task, but we've done our best to simplify it for you. The Moderation API helps handle a wide range of tasks, including:

- Moderator labeling
- User muting (including mutelists)
- User blocking
- Mutewords
- Hidden posts

## Configuration

Every moderation function takes a set of options which look like this:

```typescript
{
  // the logged-in user's DID
  userDid: 'did:plc:1234...',

  moderationPrefs: {
    // is adult content allowed?
    adultContentEnabled: true,

    // the global label settings (used on self-labels)
    labels: {
      porn: 'hide',
      sexual: 'warn',
      nudity: 'ignore',
      // ...
    },

    // the subscribed labelers and their label settings
    labelers: [
      {
        did: 'did:plc:1234...',
        labels: {
          porn: 'hide',
          sexual: 'warn',
          nudity: 'ignore',
          // ...
        }
      }
    ],

    mutedWords: [/* ... */],
    hiddenPosts: [/* ... */]
  },

  // custom label definitions
  labelDefs: {
    // labelerDid => defs[]
    'did:plc:1234...': [
      /* ... */
    ]
  }
}
```

This should match the following interfaces:

```typescript
export interface ModerationPrefsLabeler {
  did: string
  labels: Record<string, LabelPreference>
}

export interface ModerationPrefs {
  adultContentEnabled: boolean
  labels: Record<string, LabelPreference>
  labelers: ModerationPrefsLabeler[]
  mutedWords: AppBskyActorDefs.MutedWord[]
  hiddenPosts: string[]
}

export interface ModerationOpts {
  userDid: string | undefined
  prefs: ModerationPrefs
  /**
   * Map of labeler did -> custom definitions
   */
  labelDefs?: Record<string, InterpretedLabelValueDefinition[]>
}
```

You can quickly grab the `ModerationPrefs` using the `agent.getPreferences()` method:

```typescript
const prefs = await agent.getPreferences()
moderatePost(post, {
  userDid: /*...*/,
  prefs: prefs.moderationPrefs,
  labelDefs: /*...*/
})
```

To gather the label definitions (`labelDefs`) see the _Labelers_ section below.

## Labelers

Labelers are services that provide moderation labels. Your application will typically have 1+ top-level labelers set with the ability to do "takedowns" on content. This is controlled via this static function, though the default is to use Bluesky's moderation:

```typescript
BskyAgent.configure({
  appLabelers: ['did:web:my-labeler.com'],
})
```

Users may also add their own labelers. The active labelers are controlled via an HTTP header which is automatically set by the agent when `getPreferences` is called, or when the labeler preferences are changed.

Labelers publish a `app.bsky.labeler.service` record that looks like this:

```js
{
  $type: 'app.bsky.labeler.service',
  policies: {
    // the list of label values the labeler will publish
    labelValues: [
      'rude',
    ],
    // any custom definitions the labeler will be using
    labelValueDefinitions: [
      {
        identifier: 'rude',
        blurs: 'content',
        severity: 'alert',
        defaultSetting: 'warn',
        adultOnly: false,
        locales: [
          {
            lang: 'en',
            name: 'Rude',
            description: 'Not keeping things civil.',
          },
        ],
      },
    ],
  },
  createdAt: '2024-03-12T17:17:17.215Z'
}
```

The label value definition are custom labels which only apply to that labeler. Your client needs to sync those definitions in order to correctly interpret them. To do that, call `app.bsky.labeler.getService()` (or the `getServices` batch variant) periodically to fetch their definitions. We recommend caching the response (at time our writing the official client uses a TTL of 6 hours).

Here is how to do this:

```typescript
import { AtpAgent } from '@atproto/api'

const agent = new AtpAgent({ service: 'https://example.com' })
// assume `agent` is a signed in session
const prefs = await agent.getPreferences()
const labelDefs = await agent.getLabelDefinitions(prefs)

moderatePost(post, {
  userDid: agent.session.did,
  prefs: prefs.moderationPrefs,
  labelDefs,
})
```

## The `moderate*()` APIs

The SDK exports methods to moderate the different kinds of content on the network.

```typescript
import {
  moderateProfile,
  moderatePost,
  moderateNotification,
  moderateFeedGen,
  moderateUserList,
  moderateLabeler,
} from '@atproto/api'
```

Each of these follows the same API signature:

```typescript
const res = moderatePost(post, moderationOptions)
```

The response object provides an API for figuring out what your UI should do in different contexts.

```typescript
res.ui(context) /* =>

ModerationUI {
  filter: boolean // should the content be removed from the interface?
  blur: boolean // should the content be put behind a cover?
  alert: boolean // should an alert be put on the content? (negative)
  inform: boolean // should an informational notice be put on the content? (neutral)
  noOverride: boolean // if blur=true, should the UI disable opening the cover?

  // the reasons for each of the flags:
  filters: ModerationCause[]
  blurs: ModerationCause[]
  alerts: ModerationCause[]
  informs: ModerationCause[]
}
*/
```

There are multiple UI contexts available:

- `profileList` A profile being listed, eg in search or a follower list
- `profileView` A profile being viewed directly
- `avatar` The user's avatar in any context
- `banner` The user's banner in any context
- `displayName` The user's display name in any context
- `contentList` Content being listed, eg posts in a feed, posts as replies, a user list list, a feed generator list, etc
- `contentView` Content being viewed direct, eg an opened post, the user list page, the feedgen page, etc
- `contentMedia ` Media inside the content, eg a picture embedded in a post

Here's how a post in a feed would use these tools to make a decision:

```typescript
const mod = moderatePost(post, moderationOptions)

if (mod.ui('contentList').filter) {
  // dont show the post
}
if (mod.ui('contentList').blur) {
  // cover the post with the explanation from mod.ui('contentList').blurs[0]
  if (mod.ui('contentList').noOverride) {
    // dont allow the cover to be removed
  }
}
if (mod.ui('contentMedia').blur) {
  // cover the post's embedded images with the explanation from mod.ui('contentMedia').blurs[0]
  if (mod.ui('contentMedia').noOverride) {
    // dont allow the cover to be removed
  }
}
if (mod.ui('avatar').blur) {
  // cover the avatar with the explanation from mod.ui('avatar').blurs[0]
  if (mod.ui('avatar').noOverride) {
    // dont allow the cover to be removed
  }
}
for (const alert of mod.ui('contentList').alerts) {
  // render this alert
}
for (const inform of mod.ui('contentList').informs) {
  // render this inform
}
```

## Sending moderation reports

Any Labeler is capable of receiving moderation reports. As a result, you need to specify which labeler should receive the report. You do this with the `Atproto-Proxy` header:

```typescript
agent
  .withProxy('atproto_labeler', 'did:web:my-labeler.com')
  .createModerationReport({
    reasonType: 'com.atproto.moderation.defs#reasonViolation',
    reason: 'They were being such a jerk to me!',
    subject: { did: 'did:web:bob.com' },
  })
```

---

--- METRICS ---
- Files Extracted: 58
- Estimated Token Budget: ~65551 tokens
- Canonical Reference: https://codewiki.google/github.com/bluesky-social/atproto
