cpds #
cpds is a small Personal Data Server written as an executable explanation.
It hosts one AT Protocol identity, grants applications access through OAuth,
stores content-addressed records and blobs, and exposes a focused XRPC surface.
Start with the repository guide for a plain-language
tour, the source map, and small exercises. HTTP translates requests, services
enforce policy, and SQLite commits durable state. The shared cpds-protocol
crate handles record validation and content addressing without running a server.
What works #
- OAuth 2.0 authorization code flow with exact redirect matching and S256 PKCE
- short-lived, one-time authorization codes and hashed opaque access tokens
createRecord,putRecord,getRecord,listRecords,deleteRecord, and ordered, atomicapplyWritesproducing one signed repository commit- official-fixture-matched AT Protocol data-model validation and encoding, including native DAG-CBOR CID links and bytes, deterministic record CIDs, signed repository-v3 MSTs, and AT URIs
- canonical public repository head/status/list/description and collection-scoped discovery
- durable account status plus authenticated activation/deactivation, with status-gated records, blobs, exports, repository writes, and OAuth Space calls
- verified offline host suspension, takedown, deletion-state, and reversal
- bounded full and incremental CAR v1 repository, record-proof, and reachable-block export
uploadBlob, publicgetBlob, a durable ATrium-derived record/blob reference index, referenced-blob pagination, and missing-blob discovery- durable blob publication plus bounded, crash-resumable orphan collection
- alpha ATProto Spaces records in isolated per-space, per-user repositories
- required
com.atproto.simplespacecreation, configuration, and member lists - granular
space:OAuth scope matching for reads, writes, and management - permissioned record CRUD, atomic
applyWrites, metadata-only listing, space-scoped blob listing/gating, and incremental oplogs - deniable alpha LtHash commits signed by the account's secp256k1 key
- one-time delegation tokens and ES256 DPoP-bound space credentials
- credential-authenticated cross-repo reads with replay, URL, method, and token hash binding
did:webanddid:plcsigning-key resolution for cross-host space tokens- client-attestation verification from client metadata/JWKS, including replay protection, plus fail-closed managing-app access callbacks
- CAR v1 full-repo recovery, metadata-only backfill, and incremental oplogs
- space writer discovery, authenticated write/deletion notifications, and expiring subscriber registration
- a startup-validated canonical
did:webdocument exposing the signing key and PDS service endpoint - generated
resolveHandle, DNS TXT/HTTPS well-known handle resolution, and fail-closed bidirectional production identity verification - periodic production handle revalidation with durable
handle.invalidand recovery identity events - replaceable in-memory and filesystem blob stores
- durable bounded
subscribeReposidentity/account/commit/sync/info events plus typed in-process notifications - pre-listener ATrium graph, signature, block, SQL-record-index, blob-byte, and retained event-chain verification
- bounded repository-block collection that preserves current ATrium graphs and retained sync-event history
- discovery, server description, health, and authenticated session endpoints
- reproducible dependency resolution through the checked-in
Cargo.lock
Deliberate boundary #
This release is useful for local applications, protocol experiments, and as a
clear foundation. It is not yet a general federating PDS: public records are
assembled into signed repository-v3 Merkle Search Trees and committed atomically
with a durable outbox and exported as verifiable full or incremental CAR
archives, and initial identity/account plus commit/sync/outdated-cursor firehose
transport, network handle/DID resolution, explicit relay crawl requests, and
relay-style full-CAR gap repair are implemented and exercised together by a
fake relay that validates the hosted identity and resumed commit. User account
activation/deactivation, host-enforced lifecycle transitions, and durable
#account propagation and hosted-handle invalidation/recovery are implemented;
secure account erasure, account migration, DID-operation lifecycle, PAR, and
OAuth DPoP are not.
The alpha Spaces
credential, recovery, discovery, and notification protocols are implemented,
including remote authority/user key resolution. Notification delivery is
best-effort rather than a durable retrying outbox, and incoming deletion notices
are authenticated but no local replica is retained for them to remove. Stock
Bluesky clients and relays should not be pointed at it yet.
That boundary is intentional and visible. Repository mutations persist an
ordered operation, exact block membership, and versioned outbox row before
emitting their existing in-process event. Relays and AppViews can reconnect to
the bounded subscribeRepos WebSocket for durable lifecycle and repository
backfill;
the remaining federation gaps above still make public deployment premature.
The stream interoperability test acts as a relay consumer: it opens each
commit CAR with ATrium, verifies the signed head, and resolves the announced MST
record before accepting the event. Its recovery variant forces an expired
cursor, validates a full getRepo repair, reconnects live, and validates the
next inductive commit after receiving a real crawl request and resolving the
served DID/handle pair.
Run it #
Rust 1.88 or newer is expected. Cargo downloads the locked dependencies from crates.io on the first build.
cargo run --locked
No separate database server or manual SQL setup is needed. On a normal start,
CPDS opens or creates cpds.sqlite3 in the current directory and applies its
schema migrations. Blob bytes live separately in blobs/. Set
CPDS_DATABASE and CPDS_BLOB_DIRECTORY to change those locations; the database
file's parent directory must already exist and be writable. Preserve both
locations, plus the separately configured signing key, between runs.
Successful startup logs cpds is listening after the listener has bound. INFO
logging is the default; RUST_LOG=cpds=info cargo run --locked explicitly enables
CPDS's messages if an existing RUST_LOG filter hides them. In another terminal,
curl -i http://127.0.0.1:3000/ready checks storage readiness; /health only
checks that HTTP is responding. A failed startup exits with an error before
that listening message.
Before production binds its listener, CPDS resolves the configured handle using
DNS _atproto TXT records (preferred) or HTTPS /.well-known/atproto-did,
resolves the resulting did:plc or did:web document, and requires the exact
at:// handle backlink and configured DID. Conflicting valid DNS DIDs fail
closed. Identity HTTP uses HTTPS-only, credential-free URLs, public DNS answers,
bounded redirects/time/bytes, and a small TTL-bounded DNS cache. Local mode
derives its configured development identity without DNS, but initializes the
same network resolver lazily if a remote identity is requested.
The development server listens on 127.0.0.1:3000 and creates one account:
- handle:
alice.test - DID:
did:web:localhost - password:
development-only
These defaults are intentionally unsuitable for a public deployment. Configure production mode with explicit identity, storage, secret, and client values before first start:
export CPDS_ENVIRONMENT=production
export CPDS_PUBLIC_URL=https://pds.example.com/
export CPDS_DATABASE=/var/lib/cpds/cpds.sqlite3
export CPDS_BLOB_DIRECTORY=/var/lib/cpds/blobs
export CPDS_BLOB_ORPHAN_TTL_SECONDS=86400
export CPDS_BLOB_GC_INTERVAL_SECONDS=3600
export CPDS_IDENTITY_REVALIDATION_INTERVAL_SECONDS=3600
export CPDS_REPO_EVENT_RETENTION=100000
export CPDS_REPO_STREAM_SEND_TIMEOUT_MS=10000
export CPDS_RELAYS=https://bsky.network
export CPDS_ACCOUNT_DID=did:web:pds.example.com
export CPDS_ACCOUNT_HANDLE=alice.example.com
export CPDS_ACCOUNT_PASSWORD='use-a-password-manager'
export CPDS_ACCOUNT_SIGNING_KEY='base64url-encoded-32-byte-secp256k1-secret'
export CPDS_OAUTH_CLIENT_ID=https://app.example.com/client-metadata.json
export CPDS_OAUTH_REDIRECT_URI=https://app.example.com/oauth/callback
cargo run --locked
Relay discovery is an explicit operator action rather than a startup side effect. After the public DID document, DNS, TLS, and inbound sync endpoints are reachable, ask every configured relay to crawl this PDS:
cargo run --locked -- request-crawl
The command sends ATrium's generated com.atproto.sync.requestCrawl payload,
refuses redirects, retries transient transport/HTTP failures at most three
times, logs each relay result, and exits nonzero if any relay rejects or cannot
receive the request. It does not open or create repository storage, and it is
safe to rerun because crawl requests are idempotent.
Host-enforced account state is an explicit offline operator action. Run it with the same production configuration and service account that own the existing database, blob directory, and secrets:
cargo run --locked -- set-account-status suspended
The accepted states are active, deactivated, suspended, takendown, and
deleted. The command requires initialized storage, performs the exhaustive
repository/blob/event verification before and after its atomic transition,
emits an idempotent retained #account event, and exits without binding a
listener or running lifecycle collection. Production reactivation also repeats
the bidirectional network identity check before changing state. Access to the
service account's files and production secrets is the authorization boundary;
there is deliberately no network administration endpoint. deleted is a
reversible hosting state that suppresses protocol access. It does not physically
erase repository, blob, event, backup, or secret data.
Production rechecks the configured handle every
CPDS_IDENTITY_REVALIDATION_INTERVAL_SECONDS (default 3600, accepted range
60–86400). A confirmed bidirectional mismatch atomically changes the hosted
handle to handle.invalid and appends a retained #identity event without
changing the DID or account hosting status. A later successful check restores
the configured handle with another event. DNS/HTTPS transport failures are
logged as inconclusive and preserve the last confirmed state. Local mode never
runs this network maintenance task.
Startup creates and validates the blob directory before opening the listener.
On Unix, CPDS restricts blob directories to mode 0700 and blob files to
0600; it rejects a symlink as the configured root. Publications verify their
raw SHA-256 CID, sync the private temporary file, rename it atomically, and sync
the containing directory. Reads verify the CID again so disk corruption fails
closed. Temporary files older than 24 hours are removed during startup; newer
ones are retained to avoid interfering with an active writer. Use a dedicated
CPDS_BLOB_DIRECTORY beneath an existing, service-owned parent on a filesystem
that implements atomic same-directory rename and durable file/directory sync.
Structured request bodies are limited to 2 MiB; raw blob uploads use the
service's 5 MiB per-blob limit. Public and permissioned records are additionally
validated as schema-independent AT Protocol data, including native CID links
and bytes, bounded nesting and object keys, and the protocol's blessed CID
forms. They are limited to 1,000,000 bytes after canonical DAG-CBOR encoding.
Oversized HTTP requests receive HTTP 413 before the handler buffers their body;
encoded records over their protocol limit receive a typed invalid-request
response.
At most 64 requests execute concurrently. Additional requests are shed with
HTTP 503 and Retry-After: 1, and requests still running after 30 seconds are
cancelled with the same temporary-failure status. Both responses use the XRPC
JSON error shape.
Ctrl-C and SIGTERM stop accepting requests, allow up to 30 seconds for in-flight
responses, then checkpoint and truncate SQLite's WAL before exit.
Readiness executes a bounded durable-outbox cursor/oldest-timestamp query in
addition to the SQLite policy and adapter checks; corruption or loss of the
federation stream table therefore fails readiness without repeating the full
startup graph walk.
Blob downloads carry the AT Protocol CSP and nosniff isolation headers.
OAuth/browser requests support CORS. Successful well-known metadata and public
com.atproto.sync.getBlob responses cache for five minutes; Space blob responses,
errors, and other responses are no-store. A public blob download requires a
current public-record reference, not merely a successful upload or a Space
reference. Forwarding headers never override the configured public origin.
All configuration variables are parsed in src/config.rs.
CPDS_REPO_EVENT_RETENTION bounds the durable firehose window between 1 and
10,000,000 events. Consumers behind that window receive #info with
OutdatedCursor and must resynchronize from repository exports.
CPDS_REPO_STREAM_SEND_TIMEOUT_MS disconnects a firehose consumer when a
single frame cannot be sent within 100 milliseconds to one minute (10 seconds
by default). The consumer can reconnect from its last processed durable cursor.
CPDS_RELAYS is an optional comma-separated list of at most 16 relay origins.
Production entries must use HTTPS; local mode permits HTTP for fake relays.
CPDS_BLOB_ORPHAN_TTL_SECONDS retains unattached uploads for 60 seconds to one
year (24 hours by default). CPDS_BLOB_GC_INTERVAL_SECONDS runs bounded cleanup
every second to 24 hours (hourly by default). Cleanup durably claims each
orphan before deleting bytes and resumes interrupted claims. Upload metadata is
reserved first and becomes visible only after its content-addressed bytes are
durable, so interrupted publication is also recoverable.
The same maintenance pass deletes at most 1,000 repository blocks that are
outside every current ATrium graph and retained sync event. Startup runs one
pass after repository verification, and repeated passes converge without
discarding data needed by current exports or replay.
Create a consistent private backup without overwriting an existing directory:
cargo run --locked -- backup /secure/backups/cpds-2026-08-22
The command first runs the complete storage proof, then uses SQLite's native
online backup while holding a write barrier across the database snapshot and
CID-verified blob copy. It writes cpds.sqlite3, blobs/, and a non-secret
manifest.json with mode 0600 inside a mode-0700 directory, omits
publication temporaries, refuses a destination inside blob storage, and never
overwrites an existing path. Running it beside the service is consistent, but
the write barrier intentionally blocks mutations for the duration; use a
maintenance window for a large store.
The manifest excludes the account password and signing key. Protect those separately; losing the signing key requires deliberate identity and repository recovery, while putting it in every data backup would unnecessarily widen secret exposure.
To validate restored storage before exposing it, restore the backup database and blob directory to their configured paths, provide the same account identity and signing-key configuration, and run:
cargo run --locked -- verify
This offline command refuses missing or uninitialized storage, applies supported schema migrations, checks a colocated backup manifest against the public origin, DID, handle, event retention, and blob-directory pairing, then runs the complete blob, ATrium repository, SQL index, and retained-event verification walk. It does not run garbage collection or bind the configured listen address. Backup restore rehearsal is exercised by process and populated-state suites: the latter restores a signed ATrium repository, referenced blob, derived index, and retained event to new paths before verification and readback. A production operator must still automate retention, off-host transfer, and separate secret protection.
OAuth flow #
The registered development client is http://127.0.0.1:4000/client-metadata.json
with callback http://127.0.0.1:4000/callback. Start an authorization request
in a browser using an S256 code challenge:
http://127.0.0.1:3000/oauth/authorize
?response_type=code
&client_id=http%3A%2F%2F127.0.0.1%3A4000%2Fclient-metadata.json
&redirect_uri=http%3A%2F%2F127.0.0.1%3A4000%2Fcallback
&state=choose-a-random-state
&scope=atproto
&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
&code_challenge_method=S256
After approval, exchange the returned code with the matching RFC 7636 example verifier:
curl -X POST http://127.0.0.1:3000/oauth/token \
-H 'content-type: application/x-www-form-urlencoded' \
--data-urlencode grant_type=authorization_code \
--data-urlencode code="$CODE" \
--data-urlencode client_id=http://127.0.0.1:4000/client-metadata.json \
--data-urlencode redirect_uri=http://127.0.0.1:4000/callback \
--data-urlencode code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
The integration test tests/oauth_flow.rs executes this
whole flow in memory, including bearer authentication and code replay rejection.
Add and remove data #
With an access token from the OAuth flow:
curl -X POST http://127.0.0.1:3000/xrpc/com.atproto.repo.createRecord \
-H "authorization: Bearer $ACCESS_TOKEN" \
-H 'content-type: application/json' \
-d '{
"repo":"alice.test",
"collection":"app.bsky.feed.post",
"rkey":"first",
"record":{
"$type":"app.bsky.feed.post",
"text":"hello from cpds",
"createdAt":"2026-08-01T12:00:00Z"
}
}'
curl -X POST http://127.0.0.1:3000/xrpc/com.atproto.repo.deleteRecord \
-H "authorization: Bearer $ACCESS_TOKEN" \
-H 'content-type: application/json' \
-d '{"repo":"alice.test","collection":"app.bsky.feed.post","rkey":"first"}'
Try the Spaces alpha #
Request a granular scope alongside atproto. A typical locally owned forum
grant looks like this (percent-encode it when placing it in an authorization
URL):
space:com.example.forum?authority=self&collection=com.example.post&action=read&action=create&action=update&action=delete&manage=create&manage=update&manage=delete
Create the space using the alpha simplespace union discriminators:
curl -X POST http://127.0.0.1:3000/xrpc/com.atproto.simplespace.createSpace \
-H "authorization: Bearer $ACCESS_TOKEN" \
-H 'content-type: application/json' \
-d '{
"type":"com.example.forum",
"skey":"default",
"policy":{"$type":"com.atproto.simplespace.defs#memberListPolicy"},
"appAccess":{"$type":"com.atproto.simplespace.defs#open"}
}'
Write into the authenticated user's repo within that space:
curl -X POST http://127.0.0.1:3000/xrpc/com.atproto.space.createRecord \
-H "authorization: Bearer $ACCESS_TOKEN" \
-H 'content-type: application/json' \
-d '{
"space":"at://did:web:localhost/space/com.example.forum/default",
"repo":"did:web:localhost",
"collection":"com.example.post",
"rkey":"first",
"record":{"$type":"com.example.post","text":"permissioned hello"}
}'
Space records are stored separately from public records and are never returned
by com.atproto.repo.* or unauthenticated sync endpoints. Reads require a
covering read/read_self space grant; record writes additionally match the
requested action and collection; simplespace changes match manage grants.
An OAuth caller with whole-space read may mint a short-lived delegation token
through com.atproto.space.getDelegationToken, then exchange it once at the
space authority using an ES256 DPoP proof. The resulting two-hour space
credential is presented with the DPoP authorization scheme and a fresh proof
whose ath binds that request to the credential.
Extend it #
To add an object-storage backend, implement check, put, get, and delete on
BlobStore. Run
tests/blob_store_contract.rs against it; the
same contract currently proves both bundled implementations.
To observe committed changes, implement
EventSink and pass it to AppState::assemble. Every record
create/update/delete and blob store produces a typed RepositoryEvent. This is
an optional best-effort application hook; public federation uses the separate
transactional repository outbox and subscribeRepos WebSocket.
See the repository guide for the teaching sequence
and docs/architecture.md for design boundaries. The
evidence-driven roadmap in
docs/production-plan.md tracks every remaining
federation, reliability, security, operations, and local-test gate. The
ATrium evaluation records ecosystem adoption
decisions and their dated evidence; it is not a current vulnerability report.
Verify #
Run the formatter, linter, complete test suite, and locked dependency policy:
cargo fmt --all -- --check
cargo clippy --workspace --all-targets --locked -- -D warnings
cargo test --workspace --all-targets --locked
cargo deny --locked check
Use --workspace to include cpds-protocol; plain cargo test defaults to the
root package. Some tests open loopback sockets or start child processes, so a
runner that prohibits those operations cannot execute the complete suite.
CI is configured to run these gates on Rust 1.88. The dependency policy rejects advisories, unapproved licenses, wildcard requirements, and unapproved registries or Git sources; duplicate versions remain visible for deliberate review.
- Rust 100%
- Ruby <1%