feather-reader 0.3.2

A minimalist, atproto-native RSS/Atom reader in Rust β€” your feed subscriptions live in your own PDS.
Documentation

FeatherReader πŸͺΆ

CI CodeQL OpenSSF Scorecard crates.io License: AGPL-3.0

A minimalist, atproto-native RSS/Atom reader β€” written in Rust.

FeatherReader is a calm, typography-first feed reader for people who left algorithmic feeds on purpose. Its defining idea: your subscriptions, folders, stars, and read-state live as records in your own atproto PDS β€” not in the app's database. You sign in with your atproto identity, and your reading list follows you across any reader that speaks the same open lexicon. You own your data; the app just holds a cache and a login session.

Hosted at feather-reader.com, and trivial to self-host.

Status: experimental / pre-1.0. The core is built and usable, but the project is early, the on-disk formats and the lexicon may still change, and a closed invite-beta is planned before any wider launch. Treat it as something to try, not something to depend on.


Why FeatherReader

  • Own your data β€” as an open standard. Subscriptions, folders, saved items, and read-state are written as community.lexicon.rss.* records in your PDS. There's no signup and no password database: your atproto handle is your account. Because the records use a shared, vendor-neutral schema, your feed list is portable across readers, not just across FeatherReader instances.
  • Minimalist by design. A single sorted list, a distraction-free reading view, keyboard flow, dark mode. No ads, no tracking, no telemetry, no algorithm, no "discover" tab. Every feature has to earn its place against "does this make the calm reading experience better, or just bigger?"
  • Single binary, self-hostable. Rust + an embedded SQLite cache (no Postgres to run). Since 0.3.0 the atproto OAuth client is built in, so a self-host can be one process β€” or keep the Node sidecar if you prefer. Easy to run yourself either way.

The community.lexicon.rss.* standard

Most readers own your account and your export format. FeatherReader holds neither. Your data is stored under a neutral, community-owned lexicon that any atproto RSS reader can adopt β€” the same way community.lexicon.calendar.event lets any atproto calendar app read the same events. Log in anywhere with your handle and your feeds are already there. If you switch readers, there's nothing to export: the records are a shared standard.

The record types:

  • community.lexicon.rss.subscription β€” a subscribed feed
  • community.lexicon.rss.folder β€” a lightweight grouping
  • community.lexicon.rss.saved β€” a starred / saved item
  • community.lexicon.rss.readState β€” a compact per-feed read cursor

Architecture

Two views β€” where your data lives and how the running system is wired. Both diagrams adapt to your light/dark theme.

Data ownership β€” your PDS is the source of truth

Your subscriptions and read-state are records in your PDS, so the local cache is throwaway and your reading list follows you to any reader that speaks the same lexicon.

Runtime β€” one container, three processes

Caddy fronts everything on a single port — routing /oauth/* to whichever process owns the OAuth flow and the rest to the Rust server. The SQLite cache on the mounted volume is disposable; all durable state lives in your PDS. An optional follow→invite bot runs outside this container and reaches the app over POST /bot/claims; it isn't part of the core app.

The dashed links are the Node sidecar, used only on the default sidecar backend. On FEATHERREADER_REPO_BACKEND=rust the app owns the OAuth flow itself, those links do not exist, and the :8081 process is not started β€” see Choosing an OAuth backend.

Diagram sources + rendered images live in design/architecture/.

Features

  • A clean list + a distraction-free reader view β€” the headline feature.
  • Star / save-for-later and folders for lightweight organisation.
  • OPML import / export β€” the migration on-ramp and off-ramp. Import creates a subscription record per feed in your PDS; export reads them back out.
  • Subscribe by URL β€” paste a feed URL or a site URL and autodiscovery finds the feed.
  • Keyboard navigation β€” j/k move, o/Enter open, m toggle read, s star, A mark-all-read, ? for the shortcuts overlay, Esc to close.
  • Dark mode β€” system-preference-aware, with a manual toggle.
  • No-JS friendly β€” server-rendered HTML with a dash of htmx; every action also works as a plain form POST.
  • Polite fetching β€” conditional GET (ETag / Last-Modified), backoff, and an SSRF guard on every feed and identity fetch.

Known limitation: private / paid feeds

FeatherReader stores your subscriptions in your public PDS. Because those records are public, a secret-bearing feed URL (private Substack, Patreon, private podcast feeds, etc.) would leak its secret if written there. So for now FeatherReader supports public feeds only β€” a private/paid feed's URL is never saved, fetched, or sent anywhere; it is refused at submission with a clear message. Private-feed support is deliberately deferred until atproto's permissioned ("private") records ship.

Build & run

FeatherReader runs as one or two processes, depending on which OAuth backend you choose (see Choosing an OAuth backend):

  • sidecar (the default) β€” the Rust server plus a small Node OAuth sidecar that owns the atproto OAuth flow, so the Rust side never holds PDS tokens.
  • rust β€” the Rust server alone, using its own built-in atproto OAuth client. No Node.

Prerequisites: a recent stable Rust toolchain (see rust-version in Cargo.toml), plus Node.js 24 or newer only if you run the sidecar backend (it uses the built-in node:sqlite, which is stable and flagless from 24).

# 1. Build the server (always)
cargo build --release          # -> target/release/featherreader

# 2. Build the OAuth sidecar (only for FEATHERREADER_REPO_BACKEND=sidecar)
cd oauth-sidecar
npm ci
npm run build

Both processes are configured entirely through environment variables β€” there is no config file. Every knob has a sensible default, so a bare run boots and works.

  • The server reads FEATHERREADER_* variables (bind address, database path, poll interval, …), plus the SIDECAR_* URL and shared-secret pair it needs to reach the sidecar. See the table at the top of src/config.rs.
  • The sidecar reads SIDECAR_* variables (its public URL, storage path, the at-rest token-encryption key, the shared internal secret, …). See oauth-sidecar/.env.example. Not used on the rust backend.

In production the sidecar requires a real at-rest encryption key and a strong shared internal secret, and refuses to boot without them. Never commit secret values β€” the example files ship placeholders only.

Choosing an OAuth backend

FEATHERREADER_REPO_BACKEND selects which implementation performs the atproto OAuth handshake and every com.atproto.repo.* call:

sidecar (default) rust
Processes Rust server + Node sidecar Rust server only
OAuth client @atproto/oauth-client-node built in
Runtime deps Node.js none
Needs SIDECAR_* FEATHERREADER_OAUTH_ENCRYPTION_KEY

Upgrading to 0.3.0 changes nothing. The default is sidecar, so an existing deployment keeps the topology it already has until you choose otherwise.

Switching

FEATHERREADER_REPO_BACKEND=rust
FEATHERREADER_OAUTH_ENCRYPTION_KEY=<random, >=32 bytes>   # required in production

The server refuses to start if you select rust on a production-like instance without an encryption key. That table holds every user's access token, refresh token and DPoP private key; without a key they would sit in plaintext in SQLite, on the same volume as the feed cache and in every backup of it. An unrecognised backend name is also a startup failure rather than a silent fallback.

Put the signing key somewhere persistent. FEATHERREADER_OAUTH_KEY_PATH defaults to the relative oauth-signing-key.json, which is fine for a local run and a trap in a container: the key lands in the working directory, is lost on every redeploy, and a new one is generated in its place. Your published JWKS then changes on each deploy, which breaks private_key_jwt against any authorization server still holding the old one. The supplied image already points it at the persistent volume; a custom image or bare-binary deployment must do the same:

FEATHERREADER_OAUTH_KEY_PATH=/data/oauth-signing-key.json

What switching costs

  • Everyone signs in again β€” in both directions. The two backends keep separate session stores, and separate is literal: nothing under src/ reads SIDECAR_DB, because the Rust backend keeps its own oauth_session table inside FEATHERREADER_DB, apart from the sidecar's own database. No access token, refresh token or DPoP key crosses the flip, so every signed-in reader is logged out by it β€” and rolling back logs them out a second time, off a sidecar store that has gone stale in the meantime. Browser sessions are in-memory and already end on restart, so nothing is lost; it is one login per flip, which is worth timing for low traffic and telling people about.
  • Unverified, but worth knowing: the two backends publish JWKS from different signing keys, so if a PDS caches our JWKS across the flip, the first login after it may fail for that reason rather than because of a bug in the new path. This has not been observed or reproduced β€” it is a thing to rule out before concluding the backend is broken.
  • /oauth/* routing must match the backend. The two cannot share /oauth/callback: your PDS redirects there with identical ?code=&state=&iss= in both cases, so nothing in the request distinguishes them and one process has to own the path. The supplied container handles this β€” the entrypoint installs the matching Caddy routing from the same environment variable. A bare-binary deployment must route /oauth/* itself: to the app on rust, to the sidecar on sidecar.
  • Rolling back is unsetting the variable and restarting. Nothing is migrated or destroyed by the switch, and both Caddy routings ship in every image, so a rollback needs no rebuild. Cheap operationally β€” but not free for your readers, who log in again (see above).

Which should you run?

sidecar is the default and the option with production time behind it β€” it is what the hosted instance runs today. rust shipped in 0.3.0, but it has no production time at all yet: its login path is now covered by tests (the code exchange itself, and each of the security guards around it, are pinned by them), and that is a different claim from having been exercised against real PDSes under real traffic. private_key_jwt client authentication and RFC 7009 revocation in particular are implemented and unit-tested on the Rust path but have not run against a production PDS.

So: if you want the smaller deployment β€” one process, no Node β€” and are content to be early, start fresh on rust and watch your logs through the first logins. Otherwise run the default. If you already have a working sidecar deployment, there is no urgency to move.

The choice is transitional. Maintaining two implementations of the same surface has a real cost, and the intent is to remove the sidecar in a later release once the Rust path has enough production time. sidecar will be announced as deprecated before it is removed.

Self-hosting

FeatherReader is designed to be run by anyone: a single static Rust binary (optionally plus the Node sidecar), an embedded SQLite cache, and no external database. Front it with your own reverse proxy / TLS. Teardown and data-ownership notes live in deploy/.

GET /health is the unauthenticated liveness endpoint, and it reports machine facts only β€” no user counts, no DIDs, no feed URLs. The first token of the body is the state: ok, unknown or FAIL. Only a measured database failure is a failure (FAIL, HTTP 503); unknown means no probe has completed yet, which happens briefly at boot and is not an outage β€” so match the state token, not just the status code. The remaining lines (db:, uptime:, poller:, polling-paused:, backend:, oauth-runtime:) never change the status code, on the grounds that a stale poller can still serve pages while an unreadable database cannot. Alert on the body if you want to hear about those.

Invite bot (optional)

The repo also ships a small, optional follow→invite bot in bot/ — a tool for running a closed invite-beta, not needed to self-host the reader. It's a standalone Rust crate (its own workspace, deliberately not built by the app's cargo build) that watches an atproto account's followers and, for each new one, calls the app's POST /bot/claims to mint a single-use invite, then posts a public claim link. That endpoint stays disabled unless FEATHERREADER_BOT_SECRET is set, so the core app runs fine without the bot. Details + configuration in bot/README.md.

Contributing

Contributions are welcome. Please read CONTRIBUTING.md for how to build, run the checks (./scripts/ci.sh), and open a pull request. Bug reports and design discussion via issues are equally welcome.

License

AGPL-3.0-only. The AGPL is deliberate: it keeps hosted forks open, so improvements to a network-served reader flow back to everyone.