pub struct Config {Show 29 fields
pub bind: SocketAddr,
pub db_path: PathBuf,
pub public_url: String,
pub allowed_dids: Vec<String>,
pub poll_interval: Duration,
pub publication_read_deadline: Duration,
pub retention_days: u32,
pub retention_hard_days: u32,
pub publication_retention_days: u32,
pub proxy_images: bool,
pub beta_cap: i64,
pub trusted_ip_header: Option<String>,
pub max_subs_per_did: i64,
pub max_feeds_global: i64,
pub max_entries_per_feed: i64,
pub db_size_watermark_bytes: i64,
pub sidecar: SidecarConfig,
pub oauth: OauthConfig,
pub cookie_secret: String,
pub dev_did: Option<String>,
pub repo_backend: Backend,
pub standard_site: bool,
pub resolver_base: String,
pub bot_secret: Option<String>,
pub claim_ttl_secs: i64,
pub relay_hosts: Vec<String>,
pub relay_host_errors: Vec<String>,
pub adoption_interval: Duration,
pub show_adoption: bool,
}Expand description
Fully-resolved server configuration, materialized once at startup.
Fields§
§bind: SocketAddrThe socket address the HTTP server binds to.
db_path: PathBufFilesystem path to the SQLite cache/database file.
public_url: StringThe externally-reachable base URL (used to build the atproto OAuth callback and client-metadata URLs). No trailing slash.
allowed_dids: Vec<String>Optional login allow-list of atproto DIDs. Empty means the instance is open to any atproto identity that can log in.
poll_interval: DurationThe default per-feed poll interval.
publication_read_deadline: DurationThe longest one standard.site publication read may take, start to
finish. A read is otherwise bounded only per request (FETCH_TIMEOUT x
MAX_LIST_PAGES), which is hours against a repo that pages slowly.
retention_days: u32Cache eviction window, in days: a READ, UNSTARRED entry older than this
is dropped from the local cache. Starred and still-unread entries are
kept past this window — but NOT indefinitely: see retention_hard_days,
which is the bound. The PDS holds the reader’s choices, and the entry
CONTENT lives only here and at the origin feed, which usually serves just
its last few dozen items.
Two weeks by default. The cache exists to render a feed list quickly, not to archive the web.
retention_hard_days: u32Absolute cache ceiling, in days. Entries older than this are dropped REGARDLESS of starred or unread state.
This is the bound, and sparing would remove it without one. “Mark
unread” is a one-click control and entries is shared across every
reader, so an unbounded exception lets one person pin rows permanently —
and because the poller stops entirely once the database crosses
db_size_watermark_bytes, with the retention DELETE as its only release
valve, those pins could stop polling for everyone.
Losing a starred entry here is survivable: the saved record stays in the reader’s PDS and renders as a link.
publication_retention_days: u32Absolute ceiling, in days, for entries of a kind the rolling window does
not apply to — a standard.site publication today. 0 disables it.
Ten years, and the reason is that age is the wrong policy here at all.
Measured on 2026-09-27, reading three real publications through
standard_site::fetch: the newest document Standard.site offered was 131
days old, Annotated’s 109 (oldest 373), minus listens’ 241. Under the
14-day window every one of them stored zero rows — a successful poll
and an empty feed. Long-form publishing is not news-paced, so a
publication is bounded by COUNT (max_entries_per_feed, the newest N plus
up to N starred) rather than by age.
This number is therefore not a space bound; the per-feed trim is. It is the guarantee that “not aged out” does not become “immortal”: the trim only runs when a poll stores something, so entries of a feed nobody polls any more would otherwise never be reaped. Ten years is longer than the protocol itself, so it cannot truncate an archive that exists today, while still being a real bound rather than none.
Per-publication retention on the reader’s own PDS will choose inside this ceiling; the instance’s number stays the upper bound.
proxy_images: boolWhether to proxy feed images through the server (privacy vs. bandwidth).
beta_cap: i64Closed-beta seat cap: the maximum number of DIDs that may hold beta
access at once (redeeming an invite fails with CapacityFull past this).
From FEATHERREADER_BETA_CAP, default 100.
trusted_ip_header: Option<String>The reverse-proxy header the rate limiter TRUSTS for the real client IP,
e.g. Fly-Client-IP (bare Fly) or CF-Connecting-IP (Cloudflare). When
set, ONLY this header is consulted — never the spoofable multi-hop
X-Forwarded-For chain — and it falls back to the socket peer if the
header is absent/unparseable. Unset (the default) trusts the socket peer
only, which is correct for a direct bind with no proxy in front.
From FEATHERREADER_TRUSTED_IP_HEADER.
max_subs_per_did: i64Per-DID subscription cap. A DID may hold at most this many subscriptions;
add_subscription rejects over it and import_opml trims to it. Bounds
the storage/poller blast radius of one account on a small box.
From FEATHERREADER_MAX_SUBS_PER_DID, default 500.
max_feeds_global: i64Global ceiling on distinct feeds in the shared cache. A new feed is
refused once the feeds table holds this many rows (existing feeds still
poll). From FEATHERREADER_MAX_FEEDS, default 10_000.
max_entries_per_feed: i64Cap on how many entries are retained per feed on insert — the newest N by
published date; older rows are pruned in the same transaction so one
firehose feed can’t fill the disk. From FEATHERREADER_MAX_ENTRIES_PER_FEED,
default 2_000.
db_size_watermark_bytes: i64DB-size watermark, in bytes. Above it the background poller stops fetching
new content (and logs an alert) so the $3.50 box can’t be filled to a
crash. 0 disables the watermark. From FEATHERREADER_DB_SIZE_WATERMARK_BYTES,
default 2 GiB.
sidecar: SidecarConfigThe atproto OAuth sidecar wiring (base URL + shared internal secret).
oauth: OauthConfigThe Rust-native OAuth client’s own wiring. Read whatever the backend, so a misconfiguration is caught at startup rather than at the moment the switch is thrown.
HMAC key used to sign the session cookie. In production this MUST be set
(FEATHERREADER_COOKIE_SECRET); a stable dev fallback is used otherwise
so local runs work without configuration.
dev_did: Option<String>Optional dev-only DID: when set, a request with no valid session cookie is served as this DID (local runs without the OAuth sidecar). Unset in a real deployment — no session then means “logged out”.
repo_backend: BackendWhich repo implementation serves com.atproto.repo.* — the cutover
switch. Defaults to the sidecar, so deploying the Rust client changes
nothing until this is set deliberately.
standard_site: boolWhether an at:// standard.site publication subscription may be
stored — pasted into the subscribe form (a handle is resolved to its
DID first), imported via OPML, or written by another client.
From FEATHERREADER_STANDARD_SITE, default off.
This flag does not gate polling. Since 0.4.0 a stored publication
row is polled like any feed, flag on or off: the flag decides what may
be stored, and a row already stored is read. Which at:// rows are
publications, and which are Unsupported and never polled, is decided
once by crate::feed::FeedKind::of.
resolver_base: StringBase URL of the atproto handle resolver (com.atproto.identity.resolveHandle),
no trailing slash. Used by the pre-handshake beta gate to turn a submitted
handle into a DID so an existing seat can be honored on a cookie-less first
login. Defaults to crate::atproto::DEFAULT_RESOLVER_HOST. From
FEATHERREADER_RESOLVER_HOST.
bot_secret: Option<String>Shared bearer secret gating the headless bot mint endpoint (POST /bot/claims), sent by the follow→invite bot as X-Bot-Secret. When empty
the endpoint is DISABLED (503) — a bot can’t mint. Like the cookie/sidecar
secrets it MUST be set on a production-like instance (fail-loud at boot);
on a loopback/dev instance it stays unset so /bot/claims is simply off
until an operator opts in. From FEATHERREADER_BOT_SECRET.
claim_ttl_secs: i64TTL (seconds) for a claim invite code minted by POST /bot/claims. The
bot delivers the claim link asynchronously (a public skeet), so this is a
generous window — the admin-mint browser flow’s 30-minute TTL would expire
before the follower ever taps the link. From FEATHERREADER_CLAIM_TTL_SECS,
default 14 days.
relay_hosts: Vec<String>Relay bases queried for the network adoption count, as normalized origin
URLs (scheme + host, no trailing slash) — the fetch layer is handed
something crate::net can scheme-allow-list rather than being asked to
guess. An empty list disables the probe, and FEATHERREADER_RELAY_HOSTS=
(present, empty) is how an operator asks for exactly that — distinct from
leaving the variable unset, which takes the defaults. Bare hostnames are
accepted and normalized to https://….
relay_host_errors: Vec<String>Entries of FEATHERREADER_RELAY_HOSTS that were rejected as unusable, in
"value" (reason) form. Parsing happens before init_tracing, so these
are carried here and warned about by the adoption probe task instead of
being lost — or, as they were previously, aborting boot.
adoption_interval: DurationHow often the adoption probe runs. Duration::ZERO (the env value 0)
DISABLES it. From FEATHERREADER_ADOPTION_INTERVAL_SECS, default 24 h.
show_adoption: boolRender the one-line adoption fact on /about. Default false: a count
of 1 reads as a status claim rather than a fact, and the honest home for
it today is the log. From FEATHERREADER_SHOW_ADOPTION.
Implementations§
Source§impl Config
impl Config
Sourcepub fn retention_for(&self, kind: FeedKind) -> (u32, u32)
pub fn retention_for(&self, kind: FeedKind) -> (u32, u32)
The retention window that applies to entries of kind, as
(days, hard_days) for crate::store::prune_old_entries.
One home for the policy, because it has two halves that must agree.
The sweep decides what to DELETE; standard_site::ingest_floor decides
what is even worth STORING, and it is written to mirror the sweep. If the
two disagree, the store gains rows the sweep deletes and the next poll
re-inserts — the resurrection cycle, which costs a reader their read state
once per window, forever. Both sides read this.
An RSS feed gets the rolling window and the hard ceiling. A publication
gets no rolling window and the archive ceiling instead: measured, a
14-day window stored zero rows from every real publication tried, because
their newest documents were 109 to 241 days old. See
Config::publication_retention_days and feed::FeedKind::AGED.
Returning 0 for a publication’s window is load-bearing rather than
incidental: prune_old_entries honours a ceiling when days <= 0, and
ingest_floor falls through to the ceiling on the same condition.
Sourcepub fn from_env() -> Result<Self>
pub fn from_env() -> Result<Self>
Build a Config from the process environment, falling back to the
defaults above for anything unset. Returns an error only when a present
variable fails to parse — an unset variable is never an error.
Sourcepub fn did_allowed(&self, did: &str) -> bool
pub fn did_allowed(&self, did: &str) -> bool
Whether the given atproto DID is permitted to log in. When no allow-list is configured the instance is open, so every DID is allowed.
Sourcepub fn admin_seed_dids(&self) -> &[String]
pub fn admin_seed_dids(&self) -> &[String]
The admin-bootstrap seed for the closed-beta gate: the DIDs that get a
beta_access seat automatically (via crate::store::ensure_seed) so a
fresh instance always has at least the operator(s) inside the gate and
able to mint invite codes.
Reuses ALLOWED_DIDS as the seed source — the same “these are the people
I trust on this instance” concept — so operators don’t configure the list
twice. Returns a borrowed slice (empty when the instance is open / no
allow-list is set, in which case there is nothing to seed).
Trait Implementations§
Auto Trait Implementations§
impl Freeze for Config
impl RefUnwindSafe for Config
impl Send for Config
impl Sync for Config
impl Unpin for Config
impl UnsafeUnpin for Config
impl UnwindSafe for Config
Blanket Implementations§
Source§impl<T> BorrowMut<T> for Twhere
T: ?Sized,
impl<T> BorrowMut<T> for Twhere
T: ?Sized,
Source§fn borrow_mut(&mut self) -> &mut T
fn borrow_mut(&mut self) -> &mut T
Source§impl<T> CloneToUninit for Twhere
T: Clone,
impl<T> CloneToUninit for Twhere
T: Clone,
impl<A, B, T> HttpServerConnExec<A, B> for Twhere
B: Body,
Source§impl<T> Instrument for T
impl<T> Instrument for T
Source§fn instrument(self, span: Span) -> Instrumented<Self> ⓘ
fn instrument(self, span: Span) -> Instrumented<Self> ⓘ
Source§fn in_current_span(self) -> Instrumented<Self> ⓘ
fn in_current_span(self) -> Instrumented<Self> ⓘ
Source§impl<T> IntoEither for T
impl<T> IntoEither for T
Source§fn into_either(self, into_left: bool) -> Either<Self, Self> ⓘ
fn into_either(self, into_left: bool) -> Either<Self, Self> ⓘ
self into a Left variant of Either<Self, Self>
if into_left is true.
Converts self into a Right variant of Either<Self, Self>
otherwise. Read moreSource§fn into_either_with<F>(self, into_left: F) -> Either<Self, Self> ⓘ
fn into_either_with<F>(self, into_left: F) -> Either<Self, Self> ⓘ
self into a Left variant of Either<Self, Self>
if into_left(&self) returns true.
Converts self into a Right variant of Either<Self, Self>
otherwise. Read more