Skip to main content

Module atproto

Module atproto 

Source
Expand description

The atproto identity + PDS record layer.

FeatherReader’s defining bet is that a user’s feed subscriptions, folders, saved items, and batched read-state live as records in the user’s own atproto PDS under the open community.lexicon.rss.* community lexicon — not in the app’s database. This module is the client that reads and writes those records.

It has three layers:

  1. Identity resolution (resolve_handle, resolve_did_to_pds) — turn a handle (alice.example.com) into a DID (did:plc:…), then resolve the DID document to the PDS service endpoint. Handles resolve via the account’s PDS com.atproto.identity.resolveHandle (or the well-known /.well-known/atproto-did); DIDs resolve via the PLC directory (did:plc:*) or the did:web well-known document.
  2. A lightweight PdsClient — holds the resolved DID, the PDS base URL, and an Auth token, and exposes typed calls over com.atproto.repo.*: list_records, create_record, put_record, delete_record, and apply_writes (the batch call the read-state flusher uses to coalesce many per-feed cursor writes into one round-trip).
  3. Typed convenience wrappers wired to the crate::lexicon record types (list/create Subscription/Folder/Saved, put ReadState, batch-flush many ReadState cursors).

§Auth — the OAuth sidecar is the live path

Auth is a trait/enum boundary so the mechanism can vary without touching call sites. There are three paths:

  • The live path — the atproto OAuth confidential client, via SidecarClient. atproto OAuth (DPoP, PAR, token refresh) is fiddly and is not hand-rolled in Rust: it runs in a small, supported @atproto/oauth-client-node sidecar. The Rust server never holds PDS tokens — it POSTs every com.atproto.repo.* op to the sidecar’s /internal/repo endpoint (gated by a shared X-Internal-Secret), and the sidecar restores the DID’s OAuth session (transparent DPoP + token refresh) and runs the matching XRPC call. SidecarClient is that client; the typed convenience wrappers (list/create/put/delete subscriptions, batch-flush read-state) live on it and map 1:1 to the old PdsClient surface.

  • The interim path — Auth::Session (app password). A session obtained from com.atproto.server.createSession. Kept behind the Auth seam, but it is no longer the live path: PdsClient and login_with_app_password remain for tests, while SidecarClient is what the web layer routes through.

    ⚠️ This is no longer a working “local runs without the sidecar” fallback, and the docs used to claim otherwise. Since v0.2.8 every PdsClient request goes through the SSRF guard, which refuses loopback, RFC1918, ULA and 100.64/10 (Tailscale). So pointing this at http://localhost:2583 or a tailnet PDS now fails with “refusing to fetch forbidden (internal) address” rather than returning a session. That is the guard behaving correctly — the target host is attacker-influenced in the cases that matter, and a dev-only escape hatch is exactly the kind of flag that ends up set in production — but it does mean a local-PDS workflow needs the PDS reachable on a public address, or a deliberate change here.

  • The public-read path — Auth::Anonymous, via PdsClient::anonymous. com.atproto.repo.listRecords is public on a standard PDS, so a stranger’s community.lexicon.rss.* records can be read with no credentials at all. An anonymous client sends no Authorization header and is read-only — every write fails closed on Auth::bearer. Because the target host is then chosen by a stranger, the read is routed through crate::net::guarded_get_no_privacy (per-hop SSRF re-validation + connect-pinning) and capped by crate::net::read_capped.

§Every PDS request goes through the SSRF guard

A PDS host is never a host FeatherReader chose: it comes out of a DID document, which is attacker-controllable. So identity resolution, the record reads, and the record writes all route through crate::net — crate::net::guarded_get_no_privacy and crate::net::guarded_post_json — rather than the shared reqwest::Client. resolve_did_to_pds runs crate::net::assert_public_target on the serviceEndpoint it returns, but that check is a separate DNS resolution from the later request; only re-vetting and connect-pinning at request time closes the rebinding window. The writes matter most: they carry the session bearer, and login_with_app_password carries the app password in the request body, where reqwest’s cross-origin header sanitisation offers no protection at all — which is why the guarded POST refuses redirects outright.

All network I/O is reqwest (rustls, no OpenSSL); every fallible path returns anyhow::Result or the typed AtProtoError — nothing panics.

Structs§

DidDocument
The subset of a DID document FeatherReader needs: its services, so it can find the #atproto_pds endpoint.
DidService
One service entry in a DidDocument.
ListRecordsResponse
The com.atproto.repo.listRecords response envelope.
OauthPlaceholder
Placeholder for the OAuth variant of Auth.
PdsClient
A lightweight client for one user’s PDS repo.
RecordEntry
A single record as returned in a listRecords / getRecord response.
RecordWalk
The result of a bounded walk: what was read, and whether that is all of it.
RevokeResult
The sidecar’s /internal/revoke response body: { ok:true, did, revoked, hadSession }.
SessionAuth
A session obtained from com.atproto.server.createSession (interim app-password auth). Holds the DID + tokens + handle the server returned.
SidecarClient
A client for the atproto OAuth sidecar’s internal API.
SidecarSession
The {did, handle} a session-id resolves to (the sidecar’s /internal/session/:id body).
WriteResult
The com.atproto.repo.createRecord / putRecord response (a strong ref to the written record).

Enums§

AtProtoError
Errors from the atproto identity + PDS layer.
Auth
The auth material a PdsClient carries.
RepoAction
The action verbs the sidecar’s /internal/repo endpoint dispatches on.
WriteOp
One operation in a PdsClient::apply_writes batch.

Constants§

DEFAULT_PLC_DIRECTORY
The public PLC directory, used to resolve did:plc:* DIDs to their DID document (and thus their PDS service endpoint).
DEFAULT_RESOLVER_HOST
The default appview/entryway used only as a bootstrap host for handle resolution when the caller has no PDS hint yet. Handle resolution ultimately works against any atproto host that implements com.atproto.identity.resolveHandle; bsky.social is a reliable default.

Traits§

TokenSource
A source of atproto access tokens.

Functions§

login_with_app_password
Create a session with an app password via com.atproto.server.createSession.
resolve_did_to_pds
Resolve a DID to its PDS service endpoint by fetching + parsing its DID document.
resolve_handle
Resolve an atproto handle to its DID.