atoll PDS

Running a server

Test suites and drills

The opt-in integration suites, interoperability checks, and restore drills.

Checks#

Internal repository restore#

For an existing repository, a trusted caller can import a complete CAR snapshot:

{:ok, head} = Atoll.Repositories.get_head(did)
{:ok, archive} = File.read("repository.car")
Atoll.Repositories.import_archive(did, archive, head.head)

The import uses the stored public key, requires the expected head to remain unchanged, and replaces records and the head in one transaction. It accepts newer revisions or an identical-head retry. Archives must include the full tree and all records; referenced blobs and external records are not required. Unreferenced blocks are discarded. Local limits are 64 MiB per archive, 1 MB per record, and five minutes of future revision tolerance. This API does not authorize users, resolve identities, rotate keys, or validate record Lexicons.

Authenticated repository import#

POST /xrpc/com.atproto.repo.importRepo accepts a complete CAR for the access token owner's existing active or deactivated repository. Send Authorization: Bearer <accessJwt>, Content-Type: application/vnd.ipld.car, and a matching Content-Length. Compressed requests are not supported. Success returns an empty HTTP 200 response.

The endpoint verifies the snapshot's DID and signature against the repository's pinned public key. It captures the current head before reading the body and rejects replacement if another write changes it during ingestion or verification. Authorization is checked again under the write lock. Records, blob references, the head, and the sync event change atomically; identical-head retries emit no additional event. Older revisions are rejected.

Uploads are staged incrementally on disk with a 1 GiB limit, 64 KiB body reads, a five-second per-read timeout, and a 30-second overall read budget. The declared length must match the actual bytes. Imports allow ten attempts per peer IP per five minutes using the configured memory, PostgreSQL, or Redis request limiter. Blob bytes must be transferred separately. Record bodies are read individually from private staging during validation and atomic publication. Tree traversal, record publication and blob-reference reconciliation use bounded application metadata; block bodies and the staging CID/offset index reside on disk. Normal completion, malformed input, read errors, and publication failures close and remove the staging files. A VM/host crash may leave private files behind; monitor temporary-disk capacity and clean stale files operationally.

For migration, createAccount requires an existing DID, a bidirectionally verified handle, a password, and a one-use service JWT for this PDS and the createAccount method. Email is optional. It creates a deactivated account and installs the signing key reserved for that DID, or generates a new encrypted key when none was reserved. getRecommendedDidCredentials returns that key and this PDS endpoint; PLC authority-key custody is managed separately from repository signing keys. Before activation, imports may use the source key pinned during provisioning: Atoll verifies the CAR, re-signs its tree with the destination key, and tracks source revisions to reject rollback. Exact retries are idempotent. After updating the public DID document, activation checks the destination key and endpoint and clears source-key trust.

After import, GET /xrpc/com.atproto.repo.listMissingBlobs with an access token lists referenced CIDs that lack matching account-owned blob metadata. It accepts limit (1–1000, default 500) and an exclusive CID cursor, returning each CID once with a referencing recordUri. Uploading the matching bytes and MIME type removes that blob from the results. Metadata mismatches remain listed. This is an inventory of current database references, not a physical storage integrity scan; missing or corrupted backend objects require separate operational checks. Legacy access JWTs accept active or deactivated accounts; DPoP OAuth access tokens require an active account. Both share the session endpoint's 300-request per-IP, per-five-minute limit. Pagination reflects current state rather than a snapshot across requests.

GET /xrpc/com.atproto.server.checkAccountStatus accepts a legacy access JWT (including for inactive accounts) or a DPoP OAuth access token for an active account, and reports activation, current commit/revision, stored blocks referenced by retained repository revisions, current record count, distinct referenced blob CIDs, and account-owned blob count (including staged uploads). Counts describe database inventory, not backend byte integrity. privateStateValues is zero because private application state is not implemented. validDid checks the resolved signing key against the pinned repository key and the DID's PDS endpoint against Phoenix's configured public endpoint URL. Resolution failure returns validDid: false. PLC operation-log authority is checked when audit resolution is enabled; private-key availability is not checked. Remote resolution runs before inventory locks; the session is checked again before returning account data. This endpoint shares the session query rate limit and does not itself grant write access. Deactivated accounts may separately log in, refresh, import a repository, and upload blobs. Taken-down and suspended accounts may not. Migration writes preserve deactivation: public reads and public sync data remain blocked until activation. Ordinary create/put/delete/applyWrites calls remain blocked while deactivated.

Validation#

mix precommit

The test alias creates the test database and applies pending migrations. Database tests use Ecto's SQL sandbox to roll back their changes.

Database backup and restore primitives#

python3 scripts/recovery_set.py backup|verify|restore DIRECTORY pairs the database and optional S3 archives with a checksum manifest. Backup and restore require --offline; backup also takes --storage, --revision, and --keyring-reference. See the runbook for exact commands, stopped-writer prerequisites, external key/config custody and partial-failure handling.

See the logical database archive runbook for scripts/database_backup.py backup|verify|restore DIRECTORY, PostgreSQL version requirements, separate encryption-key custody, isolated restore targets, and the remaining S3/restore-drill work. The helper and full-schema Atoll drill were tested with disposable PostgreSQL 18 databases. scripts/test_atoll_database_backup.py verifies signed exports, key decryption, authentication, PostgreSQL blobs and replay state after restoring all migrations. Push CI runs both restore drills against its disposable PostgreSQL 18 service with matching archive clients. No development or production data was backed up or restored.

For S3 bytes, mix run --no-start scripts/s3_backup.exs backup|verify|restore DIRECTORY provides an offline, bounded-memory archive helper. It verifies every blob's CID, refuses a populated target blobs/ prefix, and checks upload readback. Follow the runbook's stop-writers requirements; the helper does not synchronize a database snapshot with a live bucket or preserve provider versions/metadata.

Official OAuth client integration#

An optional interop test uses the existing @atproto/oauth-client-node 0.3.16 package against a supervised Atoll server on a random localhost HTTP port:

ATOLL_ATPROTO_OAUTH_CLIENT_PATH=/absolute/path/node_modules/@atproto/oauth-client-node \
  mix test --include interop test/atoll_web/atproto_oauth_e2e_test.exs

The harness checks the package name/version without installing dependencies. The SDK performs resource and authorization discovery, PKCE/PAR with server nonce retry, authorization-code exchange and issuer verification, DPoP resource access with nonce retry, and refresh rotation. The HTTP browser harness follows login and explicit consent with cookies and CSRF fields. Base and confidential cases use SDK signOut() and require a successful revocation request, an empty grant inventory, and a surviving source session. Other cases log out the source session and verify that SDK resource access fails. Callback URLs are inspected and passed to the SDK, never fetched. Requests are restricted to the temporary server origin, with five-second request and 45-second overall deadlines. Test accounts, tokens and keys are disposable and database changes roll back.

Identity resolution uses a fixed synthetic DID document pointing at the temporary server; the SDK still checks that document's PDS against discovered issuer metadata. Nine cases cover confidential-client flows plus localhost public clients with base atproto scope, granular repository/blob grants, an email-read grant, and an RPC grant using each repository signing curve (secp256k1 and P-256). The base grant cannot write records. The granular flow requests create/update access to one collection but approves only create; the SDK receives the narrowed scope and can create records before and after refresh. Updates, deletes, cross-collection creates, and batches mixing permitted and forbidden operations return insufficient_scope. Reads and a database assertion verify preserved record contents and no partial batch writes. Refresh preserves the consented scope rather than restoring declined permissions.

The blob scenario requests text and PNG upload permissions, approving only text. It verifies successful text uploads and rejects PNG, JSON, and PNG bytes declared as text, both before and after refresh. The blob grant cannot write records, and the base grant cannot upload blobs. Database assertions compare the exact stored raw CIDs and bytes with the two permitted uploads, proving rejected uploads leave neither blob metadata nor PostgreSQL blob bytes. This harness uses PostgreSQL blob storage; it does not establish upstream OAuth interoperability with S3 storage.

The email scenario requests read and management permissions but approves only read. The SDK sees the granted email address and confirmation status, while base, repository and blob grants omit both fields. Email authentication settings remain hidden. Every scenario rejects email-update/confirmation requests and mutations with insufficient_scope, before and after refresh. The full account profile, including pending challenge fields, remains unchanged. Email delivery is disabled for the harness; no external Worker receives messages.

The RPC cases approve one method on one service audience and decline a second requested method. SDK requests obtain service JWTs before and after refresh; Node's crypto verifier independently checks each signature against the fixture's public key, plus issuer, exact audience/method, one-minute lifetime, and distinct nonces. Omitted methods, declined methods, changed service fragments, bare audiences, and foreign hosts are rejected. Other grant families cannot mint these tokens; source-session logout prevents further issuance. Already issued service JWTs remain valid until their expiry. The test does not send these tokens to an external service.

The harness uses the PostgreSQL rate limiter so counters roll back with each test; this preserves real admission limits without sharing a loopback IP budget across independent scenarios. Prior limiter and application settings are restored afterward.

The confidential-client case uses an ephemeral ES256 key and the SDK's private_key_jwt authentication for PAR, code exchange, and refresh. Atoll retrieves an inline JWKS from a controlled Req transport fixture; all authorization requests still use real loopback HTTP. Assertions are recorded in the replay store, and the persisted grant binds the client key ID, algorithm, and thumbprint. A synthetic private JWK is passed only to the child process environment and never printed; metadata contains only the public key. No dependencies or real client keys are installed or changed.

Two additional confidential cases change the fixture's advertised key after code exchange: one removes the original key ID, and one replaces its material under the same key ID. The SDK refresh receives invalid_grant; database assertions confirm deletion of the OAuth grant and every access token while retaining the source password session. The browser account session remains usable. These cases exercise observed key changes at refresh, not periodic key-checker timing or real remote metadata/JWKS HTTPS transport.

These tests do not establish live DID/handle resolution, remote client metadata interoperability, RPC proxying, identity permissions, email management or repository import, browser rendering, or complete OAuth profile compliance. The development-only HTTP identity exception is enabled only for this test and restored afterward. The interop tag remains excluded from default tests and CI because the SDK must be installed separately.

Official ATProto client integration#

An optional interop test starts Atoll on a random loopback HTTP port and drives it with @atproto/api 0.13.35, covering session login, record writes and swaps, blob upload/publication, private preference round trips (including the derived declared-age preference and anonymous rejection), read-after-write timeline splicing and unindexed-thread recovery against a stubbed stale AppView (with the client validating the munged views), and firehose consumption. It independently verifies served CAR commits, MST contents, record inclusion and deletion-absence proofs with @atproto/repo 0.8.10, using the test account's signing public key for both secp256k1 and P-256 repositories. @atproto/xrpc-server 0.7.19 supplies the upstream WebSocket subscription/frame decoder; the API package validates commit event schemas. These are pinned test versions, not a claim of compatibility with all client releases. The integration suite has been run locally with Node.js 24.13.1.

With Node.js and existing installations of those three packages, supply their absolute package-directory paths:

ATOLL_ATPROTO_API_PATH=/absolute/path/node_modules/@atproto/api \
ATOLL_ATPROTO_REPO_PATH=/absolute/path/node_modules/@atproto/repo \
ATOLL_ATPROTO_XRPC_SERVER_PATH=/absolute/path/node_modules/@atproto/xrpc-server \
  mix test --include interop test/atoll_web/atproto_client_e2e_test.exs

The harness checks package names and exact versions and does not install or modify them. It creates a synthetic account and encrypted signing-key custody in the test database's rollback sandbox. The Node client's requests are restricted to the temporary server's origin and redirects are rejected. HTTP requests and WebSocket handshakes have five-second deadlines, subscriptions have ten-second deadlines, and the overall client deadline is 45 seconds. The supervised server stops after the test; no real accounts or credentials are used.

Coverage includes password login, session inspection/refresh/revocation, schema-validated Bluesky posts with server-generated keys, create/put/delete and batch writes, rejected stale record swaps, anonymous record reads, pagination, blob upload/publication/download, full repository exports and signed record proofs. The pinned client decodes text/plain blob responses as strings; the test compares their encoded bytes with the upload. It explicitly supplies the current refresh token for session deletion.

Firehose checks begin with an independently verified baseline repository. The upstream subscriber replays create, update, batch and delete commits after that baseline cursor. The repository verifier checks each signed CAR against the prior mirror and compares its computed changes with the event's operations, including new and prior record CIDs. Tests check increasing sequences, prior revisions, prevData, commit CIDs and revisions, and the mirror's resulting records. An already connected subscriber receives a new write; a second connection resumes at the last consumed cursor and receives only the following delete. The final mirror matches the HTTP repository head. A future cursor is decoded as an upstream FutureCursor error.

This test is excluded from mix precommit and CI because it requires separately installed upstream packages. OAuth, handle/DID discovery, account creation, migration, relay federation and newer client versions are outside this test's coverage. Firehose sync fallback, identity/account events, expired replay windows and slow-consumer behavior are also outside this upstream integration test; local tests cover those paths separately. Full external-client/server interoperability remains on the checklist.

MinIO integration tests#

With Docker running, the local test PostgreSQL database available, Python 3.9+, matching PostgreSQL client tools on PATH, and a database role with CREATEDB:

bash scripts/test_minio.sh

The script builds a test image from MinIO's pinned RELEASE.2025-09-07T16-13-09Z source release, starts a disposable container on a random localhost port, waits for readiness, and runs the minio-tagged tests and the combined database/S3 restore drill. The latter uses explicit PGHOST/PGPORT/PGUSER/PGPASSWORD settings (local defaults otherwise), creates two disposable databases, and removes them after the drill. The first build downloads Go dependencies and can take several minutes; Docker caches the image for subsequent runs. Test credentials are fixed and only used in this loopback-bound container. Objects live in temporary memory-backed storage; the container is stopped and removed on exit. No production S3 credentials or buckets are used. The tests cover SigV4 uploads and downloads, the 5 MiB boundary, private buckets, account ownership, invalid credentials, missing buckets, and corrupt or missing objects. These tests are excluded from mix precommit.

GitHub Actions builds this image with Buildx and saves all build stages to the GitHub Actions layer cache, including the Go compilation stage. It loads the result into Docker and runs bash scripts/test_minio.sh --skip-build to avoid a second build. The flag requires the tagged image to exist locally. Local runs without the flag still build normally; Dockerfile changes invalidate the relevant cached layers automatically.

Type to search across every page.