feather_reader/config.rs
1//! Runtime configuration for the FeatherReader server.
2//!
3//! Everything is env-driven with a sane default for every knob, so a bare
4//! `./featherreader` boots and works — no config file required (the
5//! "trivial to self-host" promise). The environment variables
6//! all share the `FEATHERREADER_*` prefix:
7//!
8//! | Variable | Default | Meaning |
9//! |------------------------------|--------------------------|---------|
10//! | `FEATHERREADER_BIND` | `127.0.0.1:8080` | `host:port` the HTTP server binds. |
11//! | `FEATHERREADER_DB` | `featherreader.db` | Path to the SQLite cache file. |
12//! | `FEATHERREADER_PUBLIC_URL` | `http://localhost:8080` | Externally-reachable base URL (OAuth callback + client metadata). |
13//! | `FEATHERREADER_ALLOWED_DIDS` | *(empty = open)* | Comma-separated login allow-list of atproto DIDs. |
14//! | `FEATHERREADER_POLL_INTERVAL`| `3600` (1h) | Default per-feed poll interval, in seconds. At least 1; 0 is refused at startup. |
15//! | `FEATHERREADER_PUBLICATION_READ_DEADLINE_SECS` | `30` | The longest one standard.site publication read may take before it is a failure. Under Fly's 45 s `kill_timeout`, so the read in flight at shutdown can finish. Must be at least 1. |
16//! | `FEATHERREADER_STARTUP_DELAY_SECS` | unset | Shortens every background loop's delay before its FIRST tick (30/45/60/75/90 s, and 5 min for the relay probe). A **ceiling**: a larger value changes nothing and says so in the log. For dev loops and integration runs; production wants the built-in values. Read in `scheduler.rs`, listed here because this table is where an operator looks. |
17//! | `FEATHERREADER_RETENTION_HARD_DAYS` | `180` | Absolute ceiling: entries older than this go regardless of starred/unread. The bound that keeps one reader's pins from filling a shared cache and stalling the poller. `0` removes the ceiling — the ONLY bound on pinned entries, so `0` here means the cache is unbounded. Must be STRICTLY GREATER than the window below, or `0`: a ceiling inside the window would delete the rows the window spares, so it cannot be applied, and startup REFUSES the pair rather than silently running unbounded. |
18//! | `FEATHERREADER_RETENTION_DAYS`| `14` | Evict READ, UNSTARRED entries older than this. Starred and unread entries survive this window but not the hard ceiling above. `0` disables this rolling window ONLY; the ceiling still applies. Set BOTH to `0` for no eviction at all. |
19//! | `FEATHERREADER_PUBLICATION_RETENTION_DAYS` | `3650` | Absolute ceiling for entries of a kind the rolling window does not apply to — a standard.site publication. Publications are bounded by COUNT (`max_entries_per_feed`) instead of by age, because measurement says a 14-day window stores NOTHING from a real publication: the newest documents on three of them were 109 to 241 days old. This is the "not immortal" backstop, not the space bound. `0` disables it. |
20//! | `FEATHERREADER_PROXY_IMAGES` | `false` | Proxy feed images so reader IPs aren't leaked to feed hosts. |
21//! | `FEATHERREADER_TRUSTED_IP_HEADER` | *(unset)* | Trusted reverse-proxy header for the real client IP (e.g. `Fly-Client-IP`, `CF-Connecting-IP`). Unset trusts the socket peer only. |
22//! | `FEATHERREADER_MAX_SUBS_PER_DID` | `500` | Per-DID subscription cap. |
23//! | `FEATHERREADER_MAX_FEEDS` | `10000` | Global distinct-feed ceiling. |
24//! | `FEATHERREADER_MAX_ENTRIES_PER_FEED` | `2000` | Per-feed retained-entry cap (newest N). |
25//! | `FEATHERREADER_DB_SIZE_WATERMARK_BYTES` | `2 GiB` | Above this the poller stops fetching new content (0 disables). |
26//! | `FEATHERREADER_RESOLVER_HOST` | `https://bsky.social` | atproto handle-resolver base (`com.atproto.identity.resolveHandle`) the pre-handshake beta gate uses to honor an existing seat on a cookie-less login. |
27//! | `FEATHERREADER_BOT_SECRET` | *(unset = `/bot/claims` disabled)* | Shared bearer secret (`X-Bot-Secret`) gating the headless follow→invite bot's mint endpoint `POST /bot/claims`. Unset ⇒ endpoint returns 503. MUST be set (strong) on a production-like instance if the bot is used. |
28//! | `FEATHERREADER_CLAIM_TTL_SECS` | `1209600` (14 days) | TTL for a bot-minted claim invite code — long, since the claim link is delivered asynchronously (a public skeet). |
29//! | `FEATHERREADER_RELAY_HOSTS` | `relay1.us-west.bsky.network,relay1.us-east.bsky.network` | Relays queried for the network adoption count. Bare hosts or full URLs. Setting it to the **empty string** names no relays and so disables the probe (unset ⇒ the defaults above; the two are deliberately distinguished). |
30//! | `FEATHERREADER_ADOPTION_INTERVAL_SECS` | `86400` (24h) | Adoption-probe cadence (±10% jitter). `0` disables the probe. |
31//! | `FEATHERREADER_SHOW_ADOPTION` | `false` | Render the one-line adoption fact on `/about`. |
32//!
33//! The **cutover switch** and the Rust-native OAuth client it selects:
34//!
35//! | Variable | Default | Meaning |
36//! |-----------------------------------|---------------------|---------|
37//! | `FEATHERREADER_REPO_BACKEND` | `sidecar` | Which implementation serves `com.atproto.repo.*`: `sidecar` or `rust`. An unrecognised value FAILS startup rather than defaulting, since a silent fallback would make every side-by-side measurement a comparison of the sidecar with itself. The container entrypoint reads the same variable to install the matching Caddy OAuth routing — the two cannot share `/oauth/callback`, so they must agree. |
38//! | `FEATHERREADER_STANDARD_SITE` | `false` | Whether an `at://…/site.standard.publication/…` subscription may be **stored** — pasted into the subscribe form (a handle is resolved to its DID), imported via OPML, or written by another client. A stored publication **is polled** whatever this says: the flag gates storage, not reading (see `feed::FeedKind`). |
39//! | `FEATHERREADER_OAUTH_KEY_PATH` | `oauth-signing-key.json` | The client's ES256 signing key, encrypted at rest in the SAME format the sidecar writes so one file serves both and a rollback finds what it expects. |
40//! | `FEATHERREADER_OAUTH_ENCRYPTION_KEY` | *(unset = plaintext)* | At-rest encryption for the signing key and stored sessions. Generate it, do not choose it — `openssl rand -hex 32`. The value is stretched with a single SHA-256 (pinned for byte-compatibility with the sidecar's format), so its entropy is the ceiling, and the adversary this protects against is someone holding a volume snapshot with all the time in the world. |
41//! | `FEATHERREADER_PLC_DIRECTORY` | `https://plc.directory` | Directory used to resolve `did:plc` documents. |
42//! | `FEATHERREADER_OAUTH_SCOPE` | `atproto transition:generic` | Scope requested at login. Part of the dev `client_id`, so changing it changes the client's identity in dev. |
43//!
44//! The atproto OAuth sidecar (`@atproto/oauth-client-node`) is configured with a
45//! second small block — the base URL the Rust server reaches it on and the shared
46//! secret gating its internal API (see [`SidecarConfig`]):
47//!
48//! | Variable | Default | Meaning |
49//! |--------------------------------|---------------------------|---------|
50//! | `SIDECAR_PUBLIC_URL` | `http://127.0.0.1:8081` | Public base URL of the OAuth sidecar (its browser-facing `/login`, plus the OAuth `client_id`/`redirect_uri`). |
51//! | `SIDECAR_INTERNAL_URL` | *(= `SIDECAR_PUBLIC_URL`)* | Loopback base URL the Rust server reaches the sidecar's `/internal/*` API on. Defaults to the public URL for single-URL local dev. |
52//! | `SIDECAR_INTERNAL_SECRET` | *(dev fallback)* | Shared `X-Internal-Secret` for the sidecar's `/internal/*` API. |
53//! | `FEATHERREADER_COOKIE_SECRET` | *(dev fallback)* | HMAC key used to sign the session cookie. |
54//! | `FEATHERREADER_DEV_DID` | *(unset)* | When set, a request with no session cookie acts as this DID (local runs without the sidecar). |
55//!
56//! `FEATHERREADER_BIND` also accepts the design's `FEATHERREADER_ADDR` spelling
57//! as a fallback for compatibility.
58
59use std::env;
60use std::net::SocketAddr;
61use std::path::PathBuf;
62use std::time::Duration;
63
64use anyhow::{Context, Result};
65
66/// Fully-resolved server configuration, materialized once at startup.
67#[derive(Debug, Clone)]
68pub struct Config {
69 /// The socket address the HTTP server binds to.
70 pub bind: SocketAddr,
71 /// Filesystem path to the SQLite cache/database file.
72 pub db_path: PathBuf,
73 /// The externally-reachable base URL (used to build the atproto OAuth
74 /// callback and client-metadata URLs). No trailing slash.
75 pub public_url: String,
76 /// Optional login allow-list of atproto DIDs. Empty means the instance is
77 /// open to any atproto identity that can log in.
78 pub allowed_dids: Vec<String>,
79 /// The default per-feed poll interval.
80 pub poll_interval: Duration,
81 /// The longest one standard.site publication read may take, start to
82 /// finish. A read is otherwise bounded only per request (`FETCH_TIMEOUT` x
83 /// `MAX_LIST_PAGES`), which is hours against a repo that pages slowly.
84 pub publication_read_deadline: Duration,
85 /// Cache eviction window, in days: a READ, UNSTARRED entry older than this
86 /// is dropped from the local cache. Starred and still-unread entries are
87 /// kept past this window — but NOT indefinitely: see `retention_hard_days`,
88 /// which is the bound. The PDS holds the reader's choices, and the entry
89 /// CONTENT lives only here and at the origin feed, which usually serves just
90 /// its last few dozen items.
91 ///
92 /// Two weeks by default. The cache exists to render a feed list quickly,
93 /// not to archive the web.
94 pub retention_days: u32,
95 /// Absolute cache ceiling, in days. Entries older than this are dropped
96 /// REGARDLESS of starred or unread state.
97 ///
98 /// This is the bound, and sparing would remove it without one. "Mark
99 /// unread" is a one-click control and `entries` is shared across every
100 /// reader, so an unbounded exception lets one person pin rows permanently —
101 /// and because the poller stops entirely once the database crosses
102 /// `db_size_watermark_bytes`, with the retention DELETE as its only release
103 /// valve, those pins could stop polling for everyone.
104 ///
105 /// Losing a starred entry here is survivable: the saved record stays in the
106 /// reader's PDS and renders as a link.
107 pub retention_hard_days: u32,
108 /// Absolute ceiling, in days, for entries of a kind the rolling window does
109 /// **not** apply to — a standard.site publication today. `0` disables it.
110 ///
111 /// **Ten years, and the reason is that age is the wrong policy here at all.**
112 /// Measured on 2026-09-27, reading three real publications through
113 /// `standard_site::fetch`: the newest document Standard.site offered was 131
114 /// days old, Annotated's 109 (oldest 373), minus listens' 241. Under the
115 /// 14-day window every one of them stored **zero** rows — a successful poll
116 /// and an empty feed. Long-form publishing is not news-paced, so a
117 /// publication is bounded by COUNT (`max_entries_per_feed`, the newest N plus
118 /// up to N starred) rather than by age.
119 ///
120 /// This number is therefore not a space bound; the per-feed trim is. It is
121 /// the guarantee that "not aged out" does not become "immortal": the trim
122 /// only runs when a poll stores something, so entries of a feed nobody polls
123 /// any more would otherwise never be reaped. Ten years is longer than the
124 /// protocol itself, so it cannot truncate an archive that exists today, while
125 /// still being a real bound rather than none.
126 ///
127 /// Per-publication retention on the reader's own PDS will choose inside this
128 /// ceiling; the instance's number stays the upper bound.
129 pub publication_retention_days: u32,
130 /// Whether to proxy feed images through the server (privacy vs. bandwidth).
131 pub proxy_images: bool,
132 /// Closed-beta seat cap: the maximum number of DIDs that may hold beta
133 /// access at once (redeeming an invite fails with `CapacityFull` past this).
134 /// From `FEATHERREADER_BETA_CAP`, default 100.
135 pub beta_cap: i64,
136 /// The reverse-proxy header the rate limiter TRUSTS for the real client IP,
137 /// e.g. `Fly-Client-IP` (bare Fly) or `CF-Connecting-IP` (Cloudflare). When
138 /// set, ONLY this header is consulted — never the spoofable multi-hop
139 /// `X-Forwarded-For` chain — and it falls back to the socket peer if the
140 /// header is absent/unparseable. Unset (the default) trusts the socket peer
141 /// only, which is correct for a direct bind with no proxy in front.
142 /// From `FEATHERREADER_TRUSTED_IP_HEADER`.
143 pub trusted_ip_header: Option<String>,
144 /// Per-DID subscription cap. A DID may hold at most this many subscriptions;
145 /// `add_subscription` rejects over it and `import_opml` trims to it. Bounds
146 /// the storage/poller blast radius of one account on a small box.
147 /// From `FEATHERREADER_MAX_SUBS_PER_DID`, default 500.
148 pub max_subs_per_did: i64,
149 /// Global ceiling on distinct feeds in the shared cache. A new feed is
150 /// refused once the `feeds` table holds this many rows (existing feeds still
151 /// poll). From `FEATHERREADER_MAX_FEEDS`, default 10_000.
152 pub max_feeds_global: i64,
153 /// Cap on how many entries are retained per feed on insert — the newest N by
154 /// published date; older rows are pruned in the same transaction so one
155 /// firehose feed can't fill the disk. From `FEATHERREADER_MAX_ENTRIES_PER_FEED`,
156 /// default 2_000.
157 pub max_entries_per_feed: i64,
158 /// DB-size watermark, in bytes. Above it the background poller stops fetching
159 /// new content (and logs an alert) so the `$3.50 box` can't be filled to a
160 /// crash. `0` disables the watermark. From `FEATHERREADER_DB_SIZE_WATERMARK_BYTES`,
161 /// default 2 GiB.
162 pub db_size_watermark_bytes: i64,
163 /// The atproto OAuth sidecar wiring (base URL + shared internal secret).
164 pub sidecar: SidecarConfig,
165 /// The Rust-native OAuth client's own wiring. Read whatever the backend, so
166 /// a misconfiguration is caught at startup rather than at the moment the
167 /// switch is thrown.
168 pub oauth: OauthConfig,
169 /// HMAC key used to sign the session cookie. In production this MUST be set
170 /// (`FEATHERREADER_COOKIE_SECRET`); a stable dev fallback is used otherwise
171 /// so local runs work without configuration.
172 pub cookie_secret: String,
173 /// Optional dev-only DID: when set, a request with no valid session cookie
174 /// is served as this DID (local runs without the OAuth sidecar). Unset in a
175 /// real deployment — no session then means "logged out".
176 pub dev_did: Option<String>,
177 /// Which repo implementation serves `com.atproto.repo.*` — the cutover
178 /// switch. Defaults to the sidecar, so deploying the Rust client changes
179 /// nothing until this is set deliberately.
180 pub repo_backend: crate::metrics::Backend,
181 /// Whether an `at://` standard.site publication subscription may be
182 /// **stored** — pasted into the subscribe form (a handle is resolved to its
183 /// DID first), imported via OPML, or written by another client.
184 /// From `FEATHERREADER_STANDARD_SITE`, default **off**.
185 ///
186 /// **This flag does not gate polling.** Since 0.4.0 a stored publication
187 /// row is polled like any feed, flag on or off: the flag decides what may
188 /// be stored, and a row already stored is read. Which `at://` rows are
189 /// publications, and which are `Unsupported` and never polled, is decided
190 /// once by [`crate::feed::FeedKind::of`].
191 pub standard_site: bool,
192 /// Base URL of the atproto handle resolver (`com.atproto.identity.resolveHandle`),
193 /// no trailing slash. Used by the pre-handshake beta gate to turn a submitted
194 /// handle into a DID so an existing seat can be honored on a cookie-less first
195 /// login. Defaults to [`crate::atproto::DEFAULT_RESOLVER_HOST`]. From
196 /// `FEATHERREADER_RESOLVER_HOST`.
197 pub resolver_base: String,
198 /// Shared bearer secret gating the headless bot mint endpoint (`POST
199 /// /bot/claims`), sent by the follow→invite bot as `X-Bot-Secret`. When empty
200 /// the endpoint is DISABLED (503) — a bot can't mint. Like the cookie/sidecar
201 /// secrets it MUST be set on a production-like instance (fail-loud at boot);
202 /// on a loopback/dev instance it stays unset so `/bot/claims` is simply off
203 /// until an operator opts in. From `FEATHERREADER_BOT_SECRET`.
204 pub bot_secret: Option<String>,
205 /// TTL (seconds) for a claim invite code minted by `POST /bot/claims`. The
206 /// bot delivers the claim link asynchronously (a public skeet), so this is a
207 /// generous window — the admin-mint browser flow's 30-minute TTL would expire
208 /// before the follower ever taps the link. From `FEATHERREADER_CLAIM_TTL_SECS`,
209 /// default 14 days.
210 pub claim_ttl_secs: i64,
211 /// Relay bases queried for the network adoption count, as normalized origin
212 /// URLs (scheme + host, no trailing slash) — the fetch layer is handed
213 /// something [`crate::net`] can scheme-allow-list rather than being asked to
214 /// guess. An empty list disables the probe, and `FEATHERREADER_RELAY_HOSTS=`
215 /// (present, empty) is how an operator asks for exactly that — distinct from
216 /// leaving the variable unset, which takes the defaults. Bare hostnames are
217 /// accepted and normalized to `https://…`.
218 pub relay_hosts: Vec<String>,
219 /// Entries of `FEATHERREADER_RELAY_HOSTS` that were rejected as unusable, in
220 /// `"value" (reason)` form. Parsing happens before `init_tracing`, so these
221 /// are carried here and warned about by the adoption probe task instead of
222 /// being lost — or, as they were previously, aborting boot.
223 pub relay_host_errors: Vec<String>,
224 /// How often the adoption probe runs. [`Duration::ZERO`] (the env value `0`)
225 /// DISABLES it. From `FEATHERREADER_ADOPTION_INTERVAL_SECS`, default 24 h.
226 pub adoption_interval: Duration,
227 /// Render the one-line adoption fact on `/about`. Default **false**: a count
228 /// of `1` reads as a status claim rather than a fact, and the honest home for
229 /// it today is the log. From `FEATHERREADER_SHOW_ADOPTION`.
230 pub show_adoption: bool,
231}
232
233/// Configuration for the atproto OAuth sidecar (`@atproto/oauth-client-node`).
234///
235/// The Rust server drives the sidecar over two surfaces:
236/// * the **public** `${public_url}/login` URL the browser is redirected to (and
237/// which anchors the sidecar's OAuth `client_id`/`redirect_uri`), and
238/// * the **internal** `${internal_url}/internal/*` API (session lookup + the authed
239/// `com.atproto.repo.*` proxy), gated by the shared [`SidecarConfig::internal_secret`] sent as
240/// the `X-Internal-Secret` header.
241///
242/// The two URLs differ in a split deployment (public = the edge origin, internal =
243/// a loopback address the app reaches the sidecar on); they collapse to the same
244/// value in single-URL local dev.
245#[derive(Debug, Clone)]
246pub struct SidecarConfig {
247 /// Public base URL of the sidecar (no trailing slash), e.g.
248 /// `https://feather-reader.com/oauth`. Anchors the browser `/login` redirect.
249 pub public_url: String,
250 /// Loopback base URL for the sidecar's `/internal/*` API (no trailing slash),
251 /// e.g. `http://127.0.0.1:8081`. Defaults to `public_url` in single-URL dev.
252 pub internal_url: String,
253 /// Shared secret for the sidecar's internal API (`X-Internal-Secret`).
254 pub internal_secret: String,
255}
256
257/// Wiring for the Rust-native OAuth client.
258#[derive(Debug, Clone)]
259pub struct OauthConfig {
260 /// Path to the client's ES256 signing key. Encrypted at rest with
261 /// `encryption_key`, in the SAME `enc.v1` format the sidecar writes, so the
262 /// two can share one file and a rollback finds the key it expects.
263 pub key_path: PathBuf,
264 /// Passphrase for the at-rest encryption of the signing key and the stored
265 /// sessions. `None` leaves them in PLAINTEXT, which `validate_secrets`
266 /// refuses on a production-like instance when the Rust backend is selected
267 /// — that is the only configuration in which these tables are written.
268 pub encryption_key: Option<String>,
269 /// The PLC directory used to resolve `did:plc` documents.
270 pub plc_directory: String,
271 /// The OAuth scope requested at login. Part of the dev `client_id`, so
272 /// changing it changes the client's identity in dev.
273 pub scope: String,
274}
275
276/// The default PLC directory — the canonical one operated by Bluesky.
277const DEFAULT_PLC_DIRECTORY: &str = "https://plc.directory";
278
279/// The scope the reader needs: `atproto` for identity, `transition:generic` for
280/// the `com.atproto.repo.*` writes. Matches the sidecar's.
281const DEFAULT_OAUTH_SCOPE: &str = "atproto transition:generic";
282
283impl Default for OauthConfig {
284 fn default() -> Self {
285 Self {
286 key_path: PathBuf::from("oauth-signing-key.json"),
287 encryption_key: None,
288 plc_directory: DEFAULT_PLC_DIRECTORY.to_string(),
289 scope: DEFAULT_OAUTH_SCOPE.to_string(),
290 }
291 }
292}
293
294/// The sidecar's own dev fallback for the shared secret (matches the sidecar's
295/// `dev-internal-secret-change-me`) so a fully-local dev stack works untouched.
296const DEV_INTERNAL_SECRET: &str = "dev-internal-secret-change-me";
297
298/// The default sidecar base URL — loopback, matching the sidecar's own default.
299const DEFAULT_SIDECAR_URL: &str = "http://127.0.0.1:8081";
300
301/// A stable, clearly-marked dev cookie key. Overridden by
302/// `FEATHERREADER_COOKIE_SECRET` in any real deployment.
303const DEV_COOKIE_SECRET: &str = "featherreader-dev-cookie-secret-change-me";
304
305/// Default TTL for a bot-minted claim code: 14 days. Long enough that an
306/// asynchronously-delivered claim link (a public follow-back skeet) is still
307/// live when the follower taps it.
308const DEFAULT_CLAIM_TTL_SECS: i64 = 14 * 24 * 60 * 60;
309
310/// Default adoption-probe cadence: once a day. One unauthenticated GET per relay
311/// per day is the entire network cost of the feature.
312const DEFAULT_ADOPTION_INTERVAL: Duration = Duration::from_secs(86_400);
313
314impl Default for SidecarConfig {
315 fn default() -> Self {
316 Self {
317 public_url: DEFAULT_SIDECAR_URL.to_string(),
318 internal_url: DEFAULT_SIDECAR_URL.to_string(),
319 internal_secret: DEV_INTERNAL_SECRET.to_string(),
320 }
321 }
322}
323
324impl SidecarConfig {
325 /// The sidecar's public `/login` URL (the browser redirect target).
326 pub fn login_url(&self) -> String {
327 format!("{}/login", self.public_url)
328 }
329
330 /// The sidecar's `/internal/session/:id` URL (loopback internal API).
331 pub fn session_url(&self, session_id: &str) -> String {
332 format!("{}/internal/session/{}", self.internal_url, session_id)
333 }
334
335 /// The sidecar's `/internal/repo` URL (the authed `com.atproto.repo.*` proxy).
336 pub fn repo_url(&self) -> String {
337 format!("{}/internal/repo", self.internal_url)
338 }
339}
340
341/// Parse the cutover switch. Unknown values are an ERROR rather than a silent
342/// fall back to the default: a typo in `FEATHERREADER_REPO_BACKEND=rsut` that
343/// quietly kept the sidecar live would make the whole comparison a measurement
344/// of the sidecar against itself.
345fn parse_repo_backend(raw: &str) -> Result<crate::metrics::Backend> {
346 match raw.trim() {
347 "sidecar" => Ok(crate::metrics::Backend::Sidecar),
348 "rust" => Ok(crate::metrics::Backend::Rust),
349 other => anyhow::bail!(
350 "FEATHERREADER_REPO_BACKEND: expected \"sidecar\" or \"rust\", got {other:?}"
351 ),
352 }
353}
354
355impl Default for Config {
356 fn default() -> Self {
357 Self {
358 // Loopback-only by default: safe for a first run; front with a
359 // reverse proxy / tunnel to expose it.
360 bind: SocketAddr::from(([127, 0, 0, 1], 8080)),
361 db_path: PathBuf::from("featherreader.db"),
362 public_url: "http://localhost:8080".to_string(),
363 allowed_dids: Vec::new(),
364 poll_interval: Duration::from_secs(3600),
365 publication_read_deadline: Duration::from_secs(30),
366 retention_days: 14,
367 retention_hard_days: 180,
368 publication_retention_days: 3_650,
369 proxy_images: false,
370 beta_cap: 100,
371 trusted_ip_header: None,
372 max_subs_per_did: 500,
373 max_feeds_global: 10_000,
374 max_entries_per_feed: 2_000,
375 db_size_watermark_bytes: 2 * 1024 * 1024 * 1024,
376 sidecar: SidecarConfig::default(),
377 oauth: OauthConfig::default(),
378 cookie_secret: DEV_COOKIE_SECRET.to_string(),
379 // The sidecar stays the live path until the switch is thrown.
380 repo_backend: crate::metrics::Backend::Sidecar,
381 // Off by default. The flag gates STORING an at:// feed; polling is
382 // excluded by scheme in `due_feeds` regardless, until the
383 // standard.site reader is wired to the scheduler.
384 standard_site: false,
385 dev_did: None,
386 resolver_base: crate::atproto::DEFAULT_RESOLVER_HOST.to_string(),
387 bot_secret: None,
388 claim_ttl_secs: DEFAULT_CLAIM_TTL_SECS,
389 relay_host_errors: Vec::new(),
390 relay_hosts: crate::network::DEFAULT_RELAY_HOSTS
391 .iter()
392 .map(|h| format!("https://{h}"))
393 .collect(),
394 adoption_interval: DEFAULT_ADOPTION_INTERVAL,
395 show_adoption: false,
396 }
397 }
398}
399
400impl Config {
401 /// The retention window that applies to entries of `kind`, as
402 /// `(days, hard_days)` for [`crate::store::prune_old_entries`].
403 ///
404 /// **One home for the policy, because it has two halves that must agree.**
405 /// The sweep decides what to DELETE; `standard_site::ingest_floor` decides
406 /// what is even worth STORING, and it is written to mirror the sweep. If the
407 /// two disagree, the store gains rows the sweep deletes and the next poll
408 /// re-inserts — the resurrection cycle, which costs a reader their read state
409 /// once per window, forever. Both sides read this.
410 ///
411 /// An RSS feed gets the rolling window and the hard ceiling. A publication
412 /// gets **no rolling window** and the archive ceiling instead: measured, a
413 /// 14-day window stored zero rows from every real publication tried, because
414 /// their newest documents were 109 to 241 days old. See
415 /// [`Config::publication_retention_days`] and `feed::FeedKind::AGED`.
416 ///
417 /// Returning `0` for a publication's window is load-bearing rather than
418 /// incidental: `prune_old_entries` honours a ceiling when `days <= 0`, and
419 /// `ingest_floor` falls through to the ceiling on the same condition.
420 pub fn retention_for(&self, kind: crate::feed::FeedKind) -> (u32, u32) {
421 match kind {
422 crate::feed::FeedKind::Rss => (self.retention_days, self.retention_hard_days),
423 // An Unsupported row never stores entries; it inherits the archive
424 // bound so no kind outside AGED is ever unbounded.
425 crate::feed::FeedKind::Publication | crate::feed::FeedKind::Unsupported => {
426 (0, self.publication_retention_days)
427 }
428 }
429 }
430
431 /// Build a [`Config`] from the process environment, falling back to the
432 /// defaults above for anything unset. Returns an error only when a *present*
433 /// variable fails to parse — an unset variable is never an error.
434 pub fn from_env() -> Result<Self> {
435 let defaults = Config::default();
436
437 // FEATHERREADER_BIND (preferred) or FEATHERREADER_ADDR (design alias).
438 let bind = match env_opt("FEATHERREADER_BIND").or_else(|| env_opt("FEATHERREADER_ADDR")) {
439 Some(raw) => raw
440 .parse::<SocketAddr>()
441 .with_context(|| format!("FEATHERREADER_BIND: invalid socket address {raw:?}"))?,
442 None => defaults.bind,
443 };
444
445 let db_path = env_opt("FEATHERREADER_DB")
446 .map(PathBuf::from)
447 .unwrap_or(defaults.db_path);
448
449 let public_url = env_opt("FEATHERREADER_PUBLIC_URL")
450 // Normalize away a trailing slash so callers can join paths cleanly.
451 .map(|u| u.trim_end_matches('/').to_string())
452 .unwrap_or(defaults.public_url);
453
454 let allowed_dids = env_opt("FEATHERREADER_ALLOWED_DIDS")
455 .map(|raw| {
456 raw.split(',')
457 .map(str::trim)
458 .filter(|s| !s.is_empty())
459 .map(str::to_string)
460 .collect::<Vec<_>>()
461 })
462 .unwrap_or(defaults.allowed_dids);
463
464 let publication_read_deadline = publication_read_deadline_from(
465 env_opt("FEATHERREADER_PUBLICATION_READ_DEADLINE_SECS"),
466 defaults.publication_read_deadline,
467 )?;
468 let poll_interval = poll_interval_from(
469 env_opt("FEATHERREADER_POLL_INTERVAL"),
470 defaults.poll_interval,
471 )?;
472
473 let retention_hard_days = match env_opt("FEATHERREADER_RETENTION_HARD_DAYS") {
474 Some(raw) => raw.parse().with_context(|| {
475 format!("FEATHERREADER_RETENTION_HARD_DAYS: expected an integer, got {raw:?}")
476 })?,
477 None => defaults.retention_hard_days,
478 };
479
480 let retention_days = match env_opt("FEATHERREADER_RETENTION_DAYS") {
481 Some(raw) => raw.parse().with_context(|| {
482 format!("FEATHERREADER_RETENTION_DAYS: expected an integer, got {raw:?}")
483 })?,
484 None => defaults.retention_days,
485 };
486
487 let publication_retention_days = match env_opt("FEATHERREADER_PUBLICATION_RETENTION_DAYS") {
488 Some(raw) => raw.parse().with_context(|| {
489 format!(
490 "FEATHERREADER_PUBLICATION_RETENTION_DAYS: expected an integer, got {raw:?}"
491 )
492 })?,
493 None => defaults.publication_retention_days,
494 };
495
496 let proxy_images = match env_opt("FEATHERREADER_PROXY_IMAGES") {
497 Some(raw) => parse_bool(&raw).with_context(|| {
498 format!("FEATHERREADER_PROXY_IMAGES: expected a boolean, got {raw:?}")
499 })?,
500 None => defaults.proxy_images,
501 };
502
503 let beta_cap = match env_opt("FEATHERREADER_BETA_CAP") {
504 Some(raw) => raw.parse().with_context(|| {
505 format!("FEATHERREADER_BETA_CAP: expected an integer, got {raw:?}")
506 })?,
507 None => defaults.beta_cap,
508 };
509
510 // Trusted client-IP header for the rate limiter. Normalized to lowercase
511 // (header lookup is case-insensitive); unset => trust only the socket peer.
512 let trusted_ip_header =
513 env_opt("FEATHERREADER_TRUSTED_IP_HEADER").map(|h| h.trim().to_ascii_lowercase());
514
515 let max_subs_per_did = match env_opt("FEATHERREADER_MAX_SUBS_PER_DID") {
516 Some(raw) => raw.parse().with_context(|| {
517 format!("FEATHERREADER_MAX_SUBS_PER_DID: expected an integer, got {raw:?}")
518 })?,
519 None => defaults.max_subs_per_did,
520 };
521
522 let max_feeds_global = match env_opt("FEATHERREADER_MAX_FEEDS") {
523 Some(raw) => raw.parse().with_context(|| {
524 format!("FEATHERREADER_MAX_FEEDS: expected an integer, got {raw:?}")
525 })?,
526 None => defaults.max_feeds_global,
527 };
528
529 let max_entries_per_feed = match env_opt("FEATHERREADER_MAX_ENTRIES_PER_FEED") {
530 Some(raw) => raw.parse().with_context(|| {
531 format!("FEATHERREADER_MAX_ENTRIES_PER_FEED: expected an integer, got {raw:?}")
532 })?,
533 None => defaults.max_entries_per_feed,
534 };
535
536 let db_size_watermark_bytes = match env_opt("FEATHERREADER_DB_SIZE_WATERMARK_BYTES") {
537 Some(raw) => raw.parse().with_context(|| {
538 format!("FEATHERREADER_DB_SIZE_WATERMARK_BYTES: expected an integer, got {raw:?}")
539 })?,
540 None => defaults.db_size_watermark_bytes,
541 };
542
543 // --- atproto OAuth sidecar --------------------------------------
544 let sidecar_url = env_opt("SIDECAR_PUBLIC_URL")
545 .map(|u| u.trim_end_matches('/').to_string())
546 .unwrap_or_else(|| defaults.sidecar.public_url.clone());
547 // The internal API is reached over loopback in a split deployment; it
548 // falls back to the resolved public URL so single-URL local dev works.
549 let internal_url = env_opt("SIDECAR_INTERNAL_URL")
550 .map(|u| u.trim_end_matches('/').to_string())
551 .unwrap_or_else(|| sidecar_url.clone());
552 let internal_secret = env_opt("SIDECAR_INTERNAL_SECRET")
553 .unwrap_or_else(|| defaults.sidecar.internal_secret.clone());
554 let sidecar = SidecarConfig {
555 public_url: sidecar_url,
556 internal_url,
557 internal_secret,
558 };
559
560 let cookie_secret = env_opt("FEATHERREADER_COOKIE_SECRET")
561 .unwrap_or_else(|| defaults.cookie_secret.clone());
562
563 // A dev DID is opt-in: only present when explicitly configured, so a real
564 // deployment never silently falls back to a shared identity.
565 let dev_did = env_opt("FEATHERREADER_DEV_DID");
566
567 let resolver_base = env_opt("FEATHERREADER_RESOLVER_HOST")
568 .map(|u| u.trim_end_matches('/').to_string())
569 .unwrap_or(defaults.resolver_base);
570
571 // Shared bot secret gating `POST /bot/claims`. Unset => the endpoint is
572 // disabled; `validate_secrets` still fail-loud rejects the *published dev
573 // default* / a too-short value on a production-like instance.
574 let bot_secret = env_opt("FEATHERREADER_BOT_SECRET");
575
576 let claim_ttl_secs = match env_opt("FEATHERREADER_CLAIM_TTL_SECS") {
577 Some(raw) => {
578 let secs: i64 = raw.parse().with_context(|| {
579 format!("FEATHERREADER_CLAIM_TTL_SECS: expected seconds, got {raw:?}")
580 })?;
581 validate_claim_ttl(secs)?
582 }
583 None => defaults.claim_ttl_secs,
584 };
585
586 // Relay hosts for the adoption probe. Read with `env::var` and NOT with
587 // `env_opt`, which folds a present-but-empty value into `None` — i.e.
588 // straight back to the two Bluesky defaults. `FEATHERREADER_RELAY_HOSTS=`
589 // is the documented kill switch, so "set, and set to nothing" has to stay
590 // distinguishable from "not set".
591 let relay_hosts_raw = env::var("FEATHERREADER_RELAY_HOSTS").ok();
592 let (relay_hosts, relay_host_errors) =
593 parse_relay_hosts(relay_hosts_raw.as_deref(), defaults.relay_hosts);
594
595 // NOTE: parsed here, NOT via the scheduler's `env_duration_secs`, which
596 // maps `0` back to its default — that would silently turn the documented
597 // "0 disables" kill switch into "every 24 h".
598 let adoption_interval = match env_opt("FEATHERREADER_ADOPTION_INTERVAL_SECS") {
599 Some(raw) => {
600 let secs: u64 = raw.parse().with_context(|| {
601 format!("FEATHERREADER_ADOPTION_INTERVAL_SECS: expected seconds, got {raw:?}")
602 })?;
603 Duration::from_secs(secs)
604 }
605 None => defaults.adoption_interval,
606 };
607
608 let oauth = OauthConfig {
609 key_path: env_opt("FEATHERREADER_OAUTH_KEY_PATH")
610 .map(PathBuf::from)
611 .unwrap_or(defaults.oauth.key_path),
612 encryption_key: env_opt("FEATHERREADER_OAUTH_ENCRYPTION_KEY"),
613 plc_directory: env_opt("FEATHERREADER_PLC_DIRECTORY")
614 .map(|u| u.trim_end_matches('/').to_string())
615 .unwrap_or(defaults.oauth.plc_directory),
616 scope: env_opt("FEATHERREADER_OAUTH_SCOPE").unwrap_or(defaults.oauth.scope),
617 };
618
619 let repo_backend = match env_opt("FEATHERREADER_REPO_BACKEND") {
620 Some(raw) => parse_repo_backend(&raw)?,
621 None => defaults.repo_backend,
622 };
623
624 let standard_site = parse_standard_site(
625 env_opt("FEATHERREADER_STANDARD_SITE").as_deref(),
626 defaults.standard_site,
627 )?;
628
629 let show_adoption = match env_opt("FEATHERREADER_SHOW_ADOPTION") {
630 Some(raw) => parse_bool(&raw).with_context(|| {
631 format!("FEATHERREADER_SHOW_ADOPTION: expected a boolean, got {raw:?}")
632 })?,
633 None => defaults.show_adoption,
634 };
635
636 let config = Self {
637 oauth,
638 repo_backend,
639 standard_site,
640 bind,
641 db_path,
642 public_url,
643 allowed_dids,
644 poll_interval,
645 publication_read_deadline,
646 retention_days,
647 retention_hard_days,
648 publication_retention_days,
649 proxy_images,
650 beta_cap,
651 trusted_ip_header,
652 max_subs_per_did,
653 max_feeds_global,
654 max_entries_per_feed,
655 db_size_watermark_bytes,
656 sidecar,
657 cookie_secret,
658 dev_did,
659 resolver_base,
660 bot_secret,
661 claim_ttl_secs,
662 relay_hosts,
663 relay_host_errors,
664 adoption_interval,
665 show_adoption,
666 };
667
668 // FAIL LOUD: a non-loopback (public) instance must never fall back to the
669 // repo-published dev secrets — those are known to any attacker, who could
670 // then forge a session cookie offline. Refuse to boot instead.
671 config.validate_secrets()?;
672 config.validate_retention()?;
673
674 Ok(config)
675 }
676
677 /// Refuse a retention pair where the ceiling is inside the window.
678 ///
679 /// `prune_old_entries` ignores a hard ceiling that is not strictly older than
680 /// the rolling window, because applying it would delete exactly the starred
681 /// and unread rows the window exists to spare. That refusal is right, but the
682 /// fallback it lands on — no ceiling at all — is the UNBOUNDED one, and the
683 /// only signal was a `warn!` emitted once per daily sweep.
684 ///
685 /// The configuration that reaches it is not exotic. An operator who wants a
686 /// bigger cache sets `FEATHERREADER_RETENTION_DAYS=365` and leaves
687 /// `RETENTION_HARD_DAYS` at its 180-day default; `180 <= 365`, so the ceiling
688 /// silently disappears. The shared `entries` table then has no bound on rows
689 /// a reader has pinned by starring or marking unread — and `poll_due_once`
690 /// stops polling for EVERY reader once the database crosses the size
691 /// watermark, with the retention DELETE as the only release valve. One
692 /// reader can hold that valve shut permanently.
693 ///
694 /// So this is a boot refusal, matching how `FEATHERREADER_REPO_BACKEND`
695 /// treats an unrecognised value: a contradictory setting fails startup rather
696 /// than being reinterpreted into the most destructive reading available.
697 /// Both knobs off (`0`/`0`) is still allowed — that is an explicit choice to
698 /// run unbounded, not an accident of changing one variable.
699 fn validate_retention(&self) -> anyhow::Result<()> {
700 let (days, hard) = (self.retention_days, self.retention_hard_days);
701 if hard > 0 && days > 0 && hard <= days {
702 anyhow::bail!(
703 "FEATHERREADER_RETENTION_HARD_DAYS ({hard}) must be strictly greater than \
704 FEATHERREADER_RETENTION_DAYS ({days}), or 0 to disable the ceiling. A ceiling \
705 inside the window cannot be applied — it would delete exactly the starred and \
706 unread entries the window exists to spare — so it would be ignored, leaving \
707 the shared cache with NO bound on entries readers have pinned. Raise the \
708 ceiling above the window (the default pair is 14/180), or set it to 0 if you \
709 genuinely want no ceiling."
710 );
711 }
712 Ok(())
713 }
714
715 /// Whether this instance is "production-like" and therefore MUST have strong,
716 /// non-default secrets. True when `FEATHERREADER_ENV=prod`, or when either the
717 /// bind address or the public URL points at a non-loopback host — i.e. the
718 /// server is reachable by someone other than the local operator.
719 fn is_prod_like(&self) -> bool {
720 if env_opt("FEATHERREADER_ENV")
721 .map(|v| v.eq_ignore_ascii_case("prod") || v.eq_ignore_ascii_case("production"))
722 .unwrap_or(false)
723 {
724 return true;
725 }
726 // A non-loopback bind (incl. 0.0.0.0, reachable off-box) is public; so is
727 // a public_url that resolves to a non-loopback host.
728 !self.bind.ip().is_loopback() || public_url_is_non_loopback(&self.public_url)
729 }
730
731 /// Enforce the secret policy for a production-like instance. On a
732 /// loopback/dev instance the dev fallbacks are kept for convenience; on a
733 /// public one each secret must be explicitly set, not equal to its published
734 /// dev constant, and at least 32 bytes. Returns `Err` (refuse boot) otherwise.
735 fn validate_secrets(&self) -> Result<()> {
736 if !self.is_prod_like() {
737 return Ok(());
738 }
739 check_secret(
740 "FEATHERREADER_COOKIE_SECRET",
741 &self.cookie_secret,
742 DEV_COOKIE_SECRET,
743 )?;
744 check_secret(
745 "SIDECAR_INTERNAL_SECRET",
746 &self.sidecar.internal_secret,
747 DEV_INTERNAL_SECRET,
748 )?;
749 // The bot secret is OPTIONAL (unset => `/bot/claims` disabled, which is a
750 // safe default). But if it IS set on a production-like instance it must be
751 // strong — a weak/short shared bearer would let anyone mint claim codes.
752 if let Some(bot_secret) = &self.bot_secret {
753 check_secret("FEATHERREADER_BOT_SECRET", bot_secret, "")?;
754 }
755 // With the Rust backend live, `oauth_session` holds every user's access
756 // token, refresh token and DPoP PRIVATE KEY. An unset encryption key
757 // makes the codec a no-op and leaves all three in the clear in SQLite —
758 // on the same mounted volume as the feed cache, and in every snapshot
759 // and backup of it. Gated on the backend because the sidecar path never
760 // writes these tables, and blocking a rollback over a key that path does
761 // not read would be the wrong failure.
762 if self.repo_backend == crate::metrics::Backend::Rust {
763 match self.oauth.encryption_key.as_deref() {
764 Some(key) => check_secret("FEATHERREADER_OAUTH_ENCRYPTION_KEY", key, "")?,
765 None => anyhow::bail!(
766 "FEATHERREADER_REPO_BACKEND=rust on a production-like instance requires \
767 FEATHERREADER_OAUTH_ENCRYPTION_KEY: without it every stored access token, \
768 refresh token and DPoP private key is written to SQLite in plaintext. \
769 Set it to a random secret of at least {MIN_SECRET_BYTES} bytes."
770 ),
771 }
772 }
773
774 // Split-deploy footgun: on a production-like instance, if the sidecar's
775 // INTERNAL base equals its PUBLIC base and that base is non-loopback, the
776 // Rust server would send the `X-Internal-Secret` + all session/repo
777 // traffic to the PUBLIC edge URL over the network (SIDECAR_INTERNAL_URL
778 // was left unset and fell back to SIDECAR_PUBLIC_URL). The canonical
779 // container bakes SIDECAR_INTERNAL_URL to loopback; a bare-binary deploy
780 // must set it explicitly. Refuse to boot rather than leak the secret.
781 if self.sidecar.internal_url == self.sidecar.public_url
782 && public_url_is_non_loopback(&self.sidecar.public_url)
783 {
784 anyhow::bail!(
785 "SIDECAR_INTERNAL_URL is unset (defaulting to the public \
786 SIDECAR_PUBLIC_URL '{}') on a production-like instance: the internal \
787 API secret and all session/repo traffic would traverse the public \
788 network. Set SIDECAR_INTERNAL_URL to the sidecar's loopback/private \
789 address (e.g. http://127.0.0.1:8081).",
790 self.sidecar.public_url
791 );
792 }
793 Ok(())
794 }
795
796 /// Whether the given atproto DID is permitted to log in. When no allow-list
797 /// is configured the instance is open, so every DID is allowed.
798 pub fn did_allowed(&self, did: &str) -> bool {
799 self.allowed_dids.is_empty() || self.allowed_dids.iter().any(|d| d == did)
800 }
801
802 /// The admin-bootstrap seed for the closed-beta gate: the DIDs that get a
803 /// `beta_access` seat automatically (via [`crate::store::ensure_seed`]) so a
804 /// fresh instance always has at least the operator(s) inside the gate and
805 /// able to mint invite codes.
806 ///
807 /// Reuses `ALLOWED_DIDS` as the seed source — the same "these are the people
808 /// I trust on this instance" concept — so operators don't configure the list
809 /// twice. Returns a borrowed slice (empty when the instance is open / no
810 /// allow-list is set, in which case there is nothing to seed).
811 pub fn admin_seed_dids(&self) -> &[String] {
812 &self.allowed_dids
813 }
814}
815
816/// Minimum length (in bytes) for a production secret. 32 bytes = 256 bits, the
817/// floor for an HMAC-SHA256 key with a full-strength security margin.
818const MIN_SECRET_BYTES: usize = 32;
819
820/// Enforce that a production secret is set, not the published dev constant, and
821/// long enough. Returns a fail-loud `Err` naming the offending variable.
822fn check_secret(var: &str, value: &str, dev_constant: &str) -> Result<()> {
823 if value.is_empty() || value == dev_constant {
824 anyhow::bail!(
825 "{var} is unset or still the published dev default on a non-loopback (production) \
826 instance; refusing to boot. Set {var} to a random secret of at least \
827 {MIN_SECRET_BYTES} bytes."
828 );
829 }
830 if value.len() < MIN_SECRET_BYTES {
831 anyhow::bail!(
832 "{var} is too short ({} bytes) for a production instance; it must be at least \
833 {MIN_SECRET_BYTES} bytes.",
834 value.len()
835 );
836 }
837 Ok(())
838}
839
840/// Whether a `public_url` points at a non-loopback host. A parse failure or a
841/// missing host is treated as non-loopback (fail closed toward "public").
842fn public_url_is_non_loopback(public_url: &str) -> bool {
843 match url::Url::parse(public_url) {
844 Ok(u) => match u.host() {
845 Some(url::Host::Domain(d)) => {
846 !(d.eq_ignore_ascii_case("localhost") || d.eq_ignore_ascii_case("localhost."))
847 }
848 Some(url::Host::Ipv4(ip)) => !ip.is_loopback(),
849 Some(url::Host::Ipv6(ip)) => !ip.is_loopback(),
850 None => true,
851 },
852 Err(_) => true,
853 }
854}
855
856/// Validate a parsed `FEATHERREADER_CLAIM_TTL_SECS`: it MUST be positive. The
857/// bot-minted claim link is delivered ASYNCHRONOUSLY (a public skeet), so a
858/// non-positive TTL mints an instantly-expired, dead link the follower can never
859/// redeem. Fail loud at boot rather than silently hand out broken links.
860fn validate_claim_ttl(secs: i64) -> Result<i64> {
861 if secs <= 0 {
862 anyhow::bail!(
863 "FEATHERREADER_CLAIM_TTL_SECS must be > 0 (got {secs}); a non-positive TTL yields \
864 instantly-expired, dead claim links"
865 );
866 }
867 Ok(secs)
868}
869
870/// Decide `FEATHERREADER_STANDARD_SITE` from its raw value; unset means
871/// `default`, which is [`Config::default`]'s so the value lives in one place.
872/// Pure so it can be tested without touching the process environment.
873fn parse_standard_site(raw: Option<&str>, default: bool) -> Result<bool> {
874 match raw {
875 Some(raw) => parse_bool(raw)
876 .with_context(|| format!("FEATHERREADER_STANDARD_SITE={raw:?} is not a boolean")),
877 None => Ok(default),
878 }
879}
880
881/// Read an env var, treating an empty value the same as unset.
882fn env_opt(key: &str) -> Option<String> {
883 match env::var(key) {
884 Ok(v) if !v.trim().is_empty() => Some(v),
885 _ => None,
886 }
887}
888
889/// Parse `FEATHERREADER_RELAY_HOSTS` into normalized relay origin URLs.
890///
891/// `raw` is `None` **only** when the variable is genuinely absent, in which case
892/// `defaults` wins. A *present* value — including `""`, `" "`, or `","` —
893/// yields exactly the hosts it names, so an empty one yields an empty list and
894/// the probe never runs. That distinction is the whole point of this function
895/// existing rather than being inlined behind `env_opt`, which collapses
896/// present-but-empty into absent and so silently restored the two Bluesky relay
897/// defaults for an operator who had explicitly asked for none.
898///
899/// A *malformed* host is **dropped, not fatal**, and returned in the second
900/// element so the caller can surface it once logging exists.
901///
902/// This deliberately breaks the "a present-but-bad var fails loud" rule, because
903/// here that rule had a worse failure mode than the thing it was guarding:
904/// `Config::from_env` runs before `init_tracing` (`main.rs:38` vs `:41`), so a
905/// hard error is an unexplained non-zero exit, and `deploy/container-entrypoint.sh`
906/// turns that into a restart loop. A typo in an **optional metric's** host list
907/// would have taken the whole reader offline. `run_adoption_probe` already
908/// states the intended contract — "a typo'd relay host must disable an optional
909/// metric, never block boot" — and this makes it true.
910fn parse_relay_hosts(raw: Option<&str>, defaults: Vec<String>) -> (Vec<String>, Vec<String>) {
911 let Some(raw) = raw else {
912 return (defaults, Vec::new());
913 };
914 let mut hosts = Vec::new();
915 let mut rejected = Vec::new();
916 for h in raw.split(',').map(str::trim).filter(|s| !s.is_empty()) {
917 match crate::network::normalize_relay_host(h) {
918 Ok(host) => hosts.push(host),
919 Err(err) => rejected.push(format!("{h:?} ({err})")),
920 }
921 }
922 (hosts, rejected)
923}
924
925/// Parse a permissive boolean: `1/true/yes/on` vs `0/false/no/off`
926/// (case-insensitive).
927fn parse_bool(raw: &str) -> Result<bool> {
928 match raw.trim().to_ascii_lowercase().as_str() {
929 "1" | "true" | "yes" | "on" => Ok(true),
930 "0" | "false" | "no" | "off" => Ok(false),
931 other => anyhow::bail!("not a boolean: {other:?}"),
932 }
933}
934
935/// Parse `FEATHERREADER_POLL_INTERVAL`. **Zero is refused:** a healthy poll
936/// would be due again the moment it finished, and the publication loop would
937/// read every healthy publication on every pass.
938fn poll_interval_from(raw: Option<String>, default: Duration) -> Result<Duration> {
939 let Some(raw) = raw else {
940 return Ok(default);
941 };
942 let secs: u64 = raw
943 .parse()
944 .with_context(|| format!("FEATHERREADER_POLL_INTERVAL: expected seconds, got {raw:?}"))?;
945 anyhow::ensure!(
946 secs > 0,
947 "FEATHERREADER_POLL_INTERVAL must be at least 1 second"
948 );
949 Ok(Duration::from_secs(secs))
950}
951
952/// Parse `FEATHERREADER_PUBLICATION_READ_DEADLINE_SECS`. **Zero is refused:**
953/// it would fail every publication read, as `Fetch`, with nothing saying why.
954fn publication_read_deadline_from(raw: Option<String>, default: Duration) -> Result<Duration> {
955 let Some(raw) = raw else {
956 return Ok(default);
957 };
958 let secs: u64 = raw.parse().with_context(|| {
959 format!("FEATHERREADER_PUBLICATION_READ_DEADLINE_SECS: expected seconds, got {raw:?}")
960 })?;
961 anyhow::ensure!(
962 secs > 0,
963 "FEATHERREADER_PUBLICATION_READ_DEADLINE_SECS must be at least 1; 0 would fail every publication read"
964 );
965 Ok(Duration::from_secs(secs))
966}
967
968#[cfg(test)]
969mod tests {
970 use super::*;
971
972 #[test]
973 fn a_zero_poll_interval_is_refused() {
974 let d = Duration::from_secs(3600);
975 assert!(poll_interval_from(Some("0".into()), d).is_err());
976 assert_eq!(
977 poll_interval_from(Some("60".into()), d).unwrap(),
978 Duration::from_secs(60)
979 );
980 assert_eq!(poll_interval_from(None, d).unwrap(), d);
981 }
982
983 #[test]
984 fn a_zero_publication_read_deadline_is_refused() {
985 let d = Duration::from_secs(30);
986 assert!(publication_read_deadline_from(Some("0".into()), d).is_err());
987 assert_eq!(
988 publication_read_deadline_from(Some("5".into()), d).unwrap(),
989 Duration::from_secs(5)
990 );
991 assert_eq!(publication_read_deadline_from(None, d).unwrap(), d);
992 }
993
994 /// A ceiling inside the window is refused at BOOT, not ignored at sweep time.
995 ///
996 /// `prune_old_entries` correctly refuses to apply such a ceiling — it would
997 /// delete exactly the starred and unread rows the window spares — but the
998 /// fallback is "no ceiling", which is the unbounded reading. The pair is
999 /// reachable by changing ONE variable: raise `RETENTION_DAYS` to 365 and the
1000 /// default 180-day ceiling silently disappears.
1001 #[test]
1002 fn a_retention_ceiling_inside_the_window_is_refused_at_startup() {
1003 let base = Config::default();
1004 let with = |days: u32, hard: u32| Config {
1005 retention_days: days,
1006 retention_hard_days: hard,
1007 ..base.clone()
1008 };
1009
1010 // The one-variable footgun this exists for.
1011 let err = with(365, 180)
1012 .validate_retention()
1013 .expect_err("365/180 must be refused");
1014 let msg = err.to_string();
1015 assert!(
1016 msg.contains("RETENTION_HARD_DAYS"),
1017 "unhelpful message: {msg}"
1018 );
1019 assert!(msg.contains("strictly greater"), "unhelpful message: {msg}");
1020
1021 // Equal is refused too — the two cutoffs coincide, so the ceiling would
1022 // delete precisely what the window spares.
1023 assert!(with(14, 14).validate_retention().is_err());
1024 assert!(with(30, 7).validate_retention().is_err());
1025
1026 // Valid pairs.
1027 assert!(
1028 with(14, 180).validate_retention().is_ok(),
1029 "the default pair"
1030 );
1031 assert!(
1032 with(365, 400).validate_retention().is_ok(),
1033 "a bigger cache with the ceiling raised to match"
1034 );
1035 // Ceiling deliberately off: allowed, because it is an explicit choice
1036 // rather than a side effect of moving the window.
1037 assert!(with(14, 0).validate_retention().is_ok());
1038 // Window off, ceiling on: the T1.3 configuration.
1039 assert!(with(0, 180).validate_retention().is_ok());
1040 // Both off: unbounded, but explicitly so.
1041 assert!(with(0, 0).validate_retention().is_ok());
1042 }
1043
1044 #[test]
1045 fn defaults_are_sane() {
1046 let c = Config::default();
1047 assert_eq!(c.bind.port(), 8080);
1048 assert_eq!(c.poll_interval, Duration::from_secs(3600));
1049 assert_eq!(c.retention_days, 14);
1050 assert!(!c.proxy_images);
1051 assert!(c.allowed_dids.is_empty());
1052 assert_eq!(c.beta_cap, 100);
1053 // Hardening caps default to safe, non-zero bounds; no trusted proxy header.
1054 assert!(c.trusted_ip_header.is_none());
1055 assert_eq!(c.max_subs_per_did, 500);
1056 assert_eq!(c.max_feeds_global, 10_000);
1057 assert_eq!(c.max_entries_per_feed, 2_000);
1058 assert_eq!(c.db_size_watermark_bytes, 2 * 1024 * 1024 * 1024);
1059 // The adoption probe ships on (one GET per relay per day) but its
1060 // /about line ships off.
1061 assert_eq!(
1062 c.relay_hosts,
1063 vec![
1064 "https://relay1.us-west.bsky.network".to_string(),
1065 "https://relay1.us-east.bsky.network".to_string(),
1066 ]
1067 );
1068 assert_eq!(c.adoption_interval, Duration::from_secs(86_400));
1069 assert!(!c.show_adoption);
1070 }
1071
1072 /// The default host list must be exactly what `RelayClient` accepts — i.e.
1073 /// already normalized, so a default boot needs no re-parse and cannot fail.
1074 #[test]
1075 fn default_relay_hosts_are_already_normalized() {
1076 for host in Config::default().relay_hosts {
1077 assert_eq!(crate::network::normalize_relay_host(&host).unwrap(), host);
1078 }
1079 }
1080
1081 fn relay_defaults() -> Vec<String> {
1082 Config::default().relay_hosts
1083 }
1084
1085 /// **Regression (v0.2.8):** `FEATHERREADER_RELAY_HOSTS=` (present, empty) is
1086 /// the documented kill switch and must yield NO relays. Routing it through
1087 /// `env_opt` collapsed empty into absent, restoring the two Bluesky defaults
1088 /// and probing them daily against the operator's explicit instruction.
1089 #[test]
1090 fn empty_relay_hosts_env_disables_the_probe() {
1091 for raw in ["", " ", ",", " , ,\t"] {
1092 let (hosts, rejected) = parse_relay_hosts(Some(raw), relay_defaults());
1093 assert!(
1094 hosts.is_empty(),
1095 "FEATHERREADER_RELAY_HOSTS={raw:?} must name no relays, got {hosts:?}"
1096 );
1097 assert!(rejected.is_empty(), "an empty value is not a typo");
1098 }
1099 }
1100
1101 /// The other half of the same distinction: *unset* still takes the defaults.
1102 #[test]
1103 fn absent_relay_hosts_env_keeps_the_defaults() {
1104 assert_eq!(
1105 parse_relay_hosts(None, relay_defaults()).0,
1106 relay_defaults()
1107 );
1108 }
1109
1110 #[test]
1111 fn relay_hosts_env_is_split_trimmed_and_normalized() {
1112 assert_eq!(
1113 parse_relay_hosts(
1114 Some(" relay.example , https://other.example/ ,"),
1115 Vec::new()
1116 )
1117 .0,
1118 vec![
1119 "https://relay.example".to_string(),
1120 "https://other.example".to_string(),
1121 ]
1122 );
1123 }
1124
1125 /// **Regression (v0.2.8 review):** a typo used to abort `Config::from_env`,
1126 /// and because config is parsed before `init_tracing` that surfaced as an
1127 /// unexplained exit — which `container-entrypoint.sh` turns into a restart
1128 /// loop. A bad host in an OPTIONAL metric's list must never take the reader
1129 /// offline: drop it, keep the good ones, and hand the operator the reason so
1130 /// the probe task can warn.
1131 #[test]
1132 fn malformed_relay_host_is_dropped_not_fatal() {
1133 let (hosts, rejected) =
1134 parse_relay_hosts(Some("relay.example,wss://relay.example"), Vec::new());
1135 assert_eq!(hosts, vec!["https://relay.example".to_string()]);
1136 assert_eq!(rejected.len(), 1, "the bad entry is reported, not silent");
1137 assert!(rejected[0].contains("wss://relay.example"), "{rejected:?}");
1138 }
1139
1140 /// Every entry bad ⇒ no hosts ⇒ the probe disables itself, still no panic
1141 /// and still no boot failure.
1142 #[test]
1143 fn all_relay_hosts_malformed_disables_the_probe_without_failing() {
1144 let (hosts, rejected) =
1145 parse_relay_hosts(Some("wss://a.example, ftp://b.example"), relay_defaults());
1146 assert!(hosts.is_empty());
1147 assert_eq!(rejected.len(), 2);
1148 }
1149
1150 /// **Plaintext tokens must not be deployable.**
1151 ///
1152 /// With the Rust backend live, `oauth_session` holds every user's access
1153 /// token, refresh token and DPoP PRIVATE KEY. Without an encryption key the
1154 /// codec is a no-op and all three sit in the clear in SQLite — on the same
1155 /// mounted volume as the feed cache, in every snapshot and backup of it.
1156 ///
1157 /// This was found by reading a real session row during the live test: the
1158 /// stored access token began `eyJ0eXAiOiJh`, i.e. a bare JWT. The doc
1159 /// comment on `OauthConfig::encryption_key` already CLAIMED this was
1160 /// refused; it was not.
1161 #[test]
1162 fn a_production_rust_backend_refuses_to_boot_without_an_encryption_key() {
1163 let cfg = Config {
1164 repo_backend: crate::metrics::Backend::Rust,
1165 public_url: "https://feather-reader.com".into(),
1166 cookie_secret: "a-long-enough-production-cookie-secret-value".into(),
1167 sidecar: SidecarConfig {
1168 internal_secret: "a-long-enough-production-internal-secret".into(),
1169 internal_url: "http://127.0.0.1:8081".into(),
1170 ..SidecarConfig::default()
1171 },
1172 oauth: OauthConfig {
1173 encryption_key: None,
1174 ..OauthConfig::default()
1175 },
1176 ..Config::default()
1177 };
1178 let err = cfg
1179 .validate_secrets()
1180 .expect_err("plaintext tokens must not boot in production");
1181 let rendered = format!("{err:#}");
1182 assert!(
1183 rendered.contains("FEATHERREADER_OAUTH_ENCRYPTION_KEY"),
1184 "the error must name the variable to set: {rendered}"
1185 );
1186 }
1187
1188 /// The SIDECAR backend is unaffected: it stores nothing in these tables, and
1189 /// blocking a rollback over a key that path never reads would be the wrong
1190 /// failure.
1191 #[test]
1192 fn the_sidecar_backend_boots_without_an_oauth_encryption_key() {
1193 let cfg = Config {
1194 repo_backend: crate::metrics::Backend::Sidecar,
1195 public_url: "https://feather-reader.com".into(),
1196 cookie_secret: "a-long-enough-production-cookie-secret-value".into(),
1197 sidecar: SidecarConfig {
1198 internal_secret: "a-long-enough-production-internal-secret".into(),
1199 internal_url: "http://127.0.0.1:8081".into(),
1200 ..SidecarConfig::default()
1201 },
1202 oauth: OauthConfig {
1203 encryption_key: None,
1204 ..OauthConfig::default()
1205 },
1206 ..Config::default()
1207 };
1208 assert!(cfg.validate_secrets().is_ok());
1209 }
1210
1211 /// A weak key is refused on the same terms as every other secret — a short
1212 /// passphrase is stretched into an AES key, so its entropy is the ceiling.
1213 #[test]
1214 fn a_weak_oauth_encryption_key_is_refused_in_production() {
1215 let cfg = Config {
1216 repo_backend: crate::metrics::Backend::Rust,
1217 public_url: "https://feather-reader.com".into(),
1218 cookie_secret: "a-long-enough-production-cookie-secret-value".into(),
1219 sidecar: SidecarConfig {
1220 internal_secret: "a-long-enough-production-internal-secret".into(),
1221 internal_url: "http://127.0.0.1:8081".into(),
1222 ..SidecarConfig::default()
1223 },
1224 oauth: OauthConfig {
1225 encryption_key: Some("short".into()),
1226 ..OauthConfig::default()
1227 },
1228 ..Config::default()
1229 };
1230 assert!(cfg.validate_secrets().is_err());
1231 }
1232
1233 /// **A typo must fail loudly.**
1234 ///
1235 /// `FEATHERREADER_REPO_BACKEND=rsut` falling back to the default would leave
1236 /// the sidecar serving every request while the operator believed the Rust
1237 /// path was live. Every number in the comparison would then be the sidecar
1238 /// measured against itself, and the cutover would look flawless right up
1239 /// until the flag was removed.
1240 #[test]
1241 fn an_unknown_repo_backend_is_an_error_rather_than_a_silent_default() {
1242 let err = parse_repo_backend("rsut").expect_err("a typo must not be ignored");
1243 let rendered = format!("{err:#}");
1244 assert!(
1245 rendered.contains("rsut"),
1246 "the message must name the bad value: {rendered}"
1247 );
1248 assert!(rendered.contains("sidecar") && rendered.contains("rust"));
1249 }
1250
1251 /// Both spellings parse, and surrounding whitespace (a stray newline in a
1252 /// compose file or secret) does not change the backend.
1253 #[test]
1254 fn the_two_backends_parse_including_stray_whitespace() {
1255 assert_eq!(
1256 parse_repo_backend("sidecar").unwrap(),
1257 crate::metrics::Backend::Sidecar
1258 );
1259 assert_eq!(
1260 parse_repo_backend("rust").unwrap(),
1261 crate::metrics::Backend::Rust
1262 );
1263 assert_eq!(
1264 parse_repo_backend(" rust\n").unwrap(),
1265 crate::metrics::Backend::Rust
1266 );
1267 }
1268
1269 /// The default is the SIDECAR. Deploying this branch must not move anyone
1270 /// onto the new path by merely shipping; the switch has to be thrown.
1271 #[test]
1272 fn the_default_backend_is_the_sidecar() {
1273 assert_eq!(
1274 Config::default().repo_backend,
1275 crate::metrics::Backend::Sidecar
1276 );
1277 }
1278
1279 #[test]
1280 fn admin_seed_reuses_allowed_dids() {
1281 let open = Config::default();
1282 assert!(open.admin_seed_dids().is_empty());
1283 let gated = Config {
1284 allowed_dids: vec!["did:plc:me".to_string(), "did:plc:you".to_string()],
1285 ..Config::default()
1286 };
1287 assert_eq!(gated.admin_seed_dids(), &["did:plc:me", "did:plc:you"]);
1288 }
1289
1290 #[test]
1291 fn open_instance_allows_any_did() {
1292 let c = Config::default();
1293 assert!(c.did_allowed("did:plc:anything"));
1294 }
1295
1296 #[test]
1297 fn allow_list_gates_dids() {
1298 let c = Config {
1299 allowed_dids: vec!["did:plc:me".to_string()],
1300 ..Config::default()
1301 };
1302 assert!(c.did_allowed("did:plc:me"));
1303 assert!(!c.did_allowed("did:plc:stranger"));
1304 }
1305
1306 /// **The two halves of the retention policy read the same function.**
1307 ///
1308 /// The sweep deletes and the ingest floor refuses to store; written
1309 /// independently they drift, and a drift in this direction is the
1310 /// resurrection cycle — a row the store keeps, the sweep deletes, and the next
1311 /// poll re-inserts unread.
1312 #[test]
1313 fn retention_for_gives_a_publication_the_archive_ceiling_and_no_window() {
1314 let config = Config::default();
1315 assert_eq!(
1316 config.retention_for(crate::feed::FeedKind::Rss),
1317 (14, 180),
1318 "an RSS feed must keep the rolling window and the hard ceiling",
1319 );
1320 assert_eq!(
1321 config.retention_for(crate::feed::FeedKind::Publication),
1322 (0, 3_650),
1323 "a publication gets NO rolling window and the archive ceiling — a \
1324 14-day window stored zero rows from every real publication measured",
1325 );
1326 // The zero is load-bearing, not cosmetic: `prune_old_entries` honours a
1327 // ceiling only when the window is off (or strictly tighter), and
1328 // `ingest_floor` falls through to the ceiling on the same condition.
1329 let (days, hard) = config.retention_for(crate::feed::FeedKind::Publication);
1330 assert_eq!(days, 0);
1331 assert!(hard > 0);
1332 }
1333
1334 #[test]
1335 fn publication_retention_defaults_to_ten_years() {
1336 // Ten years is longer than the protocol, so it cannot truncate an archive
1337 // that exists today; the per-feed count cap is the space bound.
1338 assert_eq!(Config::default().publication_retention_days, 3_650);
1339 }
1340
1341 /// **`FEATHERREADER_STANDARD_SITE` is off unless it is set on.**
1342 ///
1343 /// The loader reads the process environment, which parallel tests cannot
1344 /// safely mutate, so the decision is a pure function of the raw value and
1345 /// tested as one. The case that matters is `None`: an unset flag must be
1346 /// `false`, or every deployment that never heard of standard.site would
1347 /// start accepting `at://` rows the poller skips.
1348 #[test]
1349 fn standard_site_is_off_unless_set_on() {
1350 let default = Config::default().standard_site;
1351 assert!(!default, "the shipped default must be off");
1352 // Unset means THE default, whatever it is — not a second copy of it.
1353 assert!(
1354 !parse_standard_site(None, false).unwrap(),
1355 "unset must mean off"
1356 );
1357 assert!(
1358 parse_standard_site(None, true).unwrap(),
1359 "unset must follow the default"
1360 );
1361 assert!(!parse_standard_site(Some("false"), true).unwrap());
1362 assert!(parse_standard_site(Some("true"), false).unwrap());
1363 assert!(parse_standard_site(Some("1"), false).unwrap());
1364 let err = parse_standard_site(Some("maybe"), false).unwrap_err();
1365 assert!(
1366 format!("{err:#}").contains("FEATHERREADER_STANDARD_SITE"),
1367 "the error must name the variable: {err:#}"
1368 );
1369 }
1370
1371 #[test]
1372 fn parse_bool_accepts_common_spellings() {
1373 assert!(parse_bool("Yes").unwrap());
1374 assert!(!parse_bool("OFF").unwrap());
1375 assert!(parse_bool("maybe").is_err());
1376 }
1377
1378 #[test]
1379 fn loopback_instance_keeps_dev_fallback_secrets() {
1380 // Default config is loopback + dev secrets: must be allowed to boot.
1381 let c = Config::default();
1382 assert!(!c.is_prod_like());
1383 assert!(c.validate_secrets().is_ok());
1384 }
1385
1386 #[test]
1387 fn public_bind_with_dev_cookie_secret_refuses_boot() {
1388 let c = Config {
1389 bind: SocketAddr::from(([0, 0, 0, 0], 8080)),
1390 ..Config::default()
1391 };
1392 assert!(c.is_prod_like());
1393 // Still carries the published dev cookie secret → must fail loud.
1394 let err = c.validate_secrets().unwrap_err().to_string();
1395 assert!(err.contains("FEATHERREADER_COOKIE_SECRET"), "{err}");
1396 }
1397
1398 #[test]
1399 fn public_bind_with_short_secret_refuses_boot() {
1400 let c = Config {
1401 bind: SocketAddr::from(([203, 0, 113, 5], 8080)),
1402 cookie_secret: "too-short".to_string(),
1403 ..Config::default()
1404 };
1405 assert!(c.is_prod_like());
1406 assert!(c.validate_secrets().is_err());
1407 }
1408
1409 #[test]
1410 fn public_bind_with_dev_sidecar_secret_refuses_boot() {
1411 let c = Config {
1412 bind: SocketAddr::from(([203, 0, 113, 5], 8080)),
1413 // Strong cookie secret, but sidecar secret still the dev default.
1414 cookie_secret: "x".repeat(48),
1415 ..Config::default()
1416 };
1417 let err = c.validate_secrets().unwrap_err().to_string();
1418 assert!(err.contains("SIDECAR_INTERNAL_SECRET"), "{err}");
1419 }
1420
1421 #[test]
1422 fn public_bind_with_strong_secrets_boots() {
1423 let c = Config {
1424 bind: SocketAddr::from(([203, 0, 113, 5], 8080)),
1425 cookie_secret: "a".repeat(48),
1426 sidecar: SidecarConfig {
1427 public_url: DEFAULT_SIDECAR_URL.to_string(),
1428 internal_url: DEFAULT_SIDECAR_URL.to_string(),
1429 internal_secret: "b".repeat(48),
1430 },
1431 ..Config::default()
1432 };
1433 assert!(c.is_prod_like());
1434 assert!(c.validate_secrets().is_ok());
1435 }
1436
1437 #[test]
1438 fn public_sidecar_url_without_internal_url_refuses_boot() {
1439 // Strong secrets, but the sidecar internal URL fell back to a
1440 // non-loopback public URL → the internal secret would go over the wire.
1441 let c = Config {
1442 bind: SocketAddr::from(([203, 0, 113, 5], 8080)),
1443 cookie_secret: "a".repeat(48),
1444 sidecar: SidecarConfig {
1445 public_url: "https://feather-reader.com/oauth".to_string(),
1446 internal_url: "https://feather-reader.com/oauth".to_string(),
1447 internal_secret: "b".repeat(48),
1448 },
1449 ..Config::default()
1450 };
1451 let err = c.validate_secrets().unwrap_err().to_string();
1452 assert!(err.contains("SIDECAR_INTERNAL_URL"), "{err}");
1453 }
1454
1455 #[test]
1456 fn public_sidecar_url_with_loopback_internal_url_boots() {
1457 // Same public sidecar URL, but an explicit loopback internal URL: safe.
1458 let c = Config {
1459 bind: SocketAddr::from(([203, 0, 113, 5], 8080)),
1460 cookie_secret: "a".repeat(48),
1461 sidecar: SidecarConfig {
1462 public_url: "https://feather-reader.com/oauth".to_string(),
1463 internal_url: "http://127.0.0.1:8081".to_string(),
1464 internal_secret: "b".repeat(48),
1465 },
1466 ..Config::default()
1467 };
1468 assert!(c.validate_secrets().is_ok());
1469 }
1470
1471 #[test]
1472 fn bot_secret_defaults_unset() {
1473 let c = Config::default();
1474 assert!(c.bot_secret.is_none());
1475 assert_eq!(c.claim_ttl_secs, DEFAULT_CLAIM_TTL_SECS);
1476 }
1477
1478 #[test]
1479 fn claim_ttl_must_be_positive() {
1480 // A positive TTL passes through unchanged.
1481 assert_eq!(validate_claim_ttl(3600).unwrap(), 3600);
1482 assert_eq!(
1483 validate_claim_ttl(DEFAULT_CLAIM_TTL_SECS).unwrap(),
1484 DEFAULT_CLAIM_TTL_SECS
1485 );
1486 // Zero and negative are rejected loudly (they mint dead, expired links).
1487 for bad in [0, -1, -1209600] {
1488 let err = validate_claim_ttl(bad).unwrap_err().to_string();
1489 assert!(err.contains("FEATHERREADER_CLAIM_TTL_SECS"), "{err}");
1490 assert!(err.contains("must be > 0"), "{err}");
1491 }
1492 }
1493
1494 #[test]
1495 fn loopback_instance_allows_weak_bot_secret() {
1496 // On a dev/loopback instance the bot secret isn't validated (the whole
1497 // secret policy is skipped), so even a short one is accepted.
1498 let c = Config {
1499 bot_secret: Some("short".to_string()),
1500 ..Config::default()
1501 };
1502 assert!(!c.is_prod_like());
1503 assert!(c.validate_secrets().is_ok());
1504 }
1505
1506 #[test]
1507 fn public_bind_with_short_bot_secret_refuses_boot() {
1508 let c = Config {
1509 bind: SocketAddr::from(([203, 0, 113, 5], 8080)),
1510 cookie_secret: "a".repeat(48),
1511 sidecar: SidecarConfig {
1512 public_url: DEFAULT_SIDECAR_URL.to_string(),
1513 internal_url: DEFAULT_SIDECAR_URL.to_string(),
1514 internal_secret: "b".repeat(48),
1515 },
1516 bot_secret: Some("too-short".to_string()),
1517 ..Config::default()
1518 };
1519 let err = c.validate_secrets().unwrap_err().to_string();
1520 assert!(err.contains("FEATHERREADER_BOT_SECRET"), "{err}");
1521 }
1522
1523 #[test]
1524 fn public_bind_with_strong_bot_secret_boots() {
1525 let c = Config {
1526 bind: SocketAddr::from(([203, 0, 113, 5], 8080)),
1527 cookie_secret: "a".repeat(48),
1528 sidecar: SidecarConfig {
1529 public_url: DEFAULT_SIDECAR_URL.to_string(),
1530 internal_url: DEFAULT_SIDECAR_URL.to_string(),
1531 internal_secret: "b".repeat(48),
1532 },
1533 bot_secret: Some("c".repeat(48)),
1534 ..Config::default()
1535 };
1536 assert!(c.validate_secrets().is_ok());
1537 }
1538
1539 #[test]
1540 fn public_bind_with_unset_bot_secret_boots() {
1541 // An unset bot secret is fine on prod (the endpoint is just disabled).
1542 let c = Config {
1543 bind: SocketAddr::from(([203, 0, 113, 5], 8080)),
1544 cookie_secret: "a".repeat(48),
1545 sidecar: SidecarConfig {
1546 public_url: DEFAULT_SIDECAR_URL.to_string(),
1547 internal_url: DEFAULT_SIDECAR_URL.to_string(),
1548 internal_secret: "b".repeat(48),
1549 },
1550 bot_secret: None,
1551 ..Config::default()
1552 };
1553 assert!(c.validate_secrets().is_ok());
1554 }
1555
1556 #[test]
1557 fn public_url_non_loopback_detection() {
1558 assert!(!public_url_is_non_loopback("http://localhost:8080"));
1559 assert!(!public_url_is_non_loopback("http://127.0.0.1:8080"));
1560 assert!(!public_url_is_non_loopback("http://[::1]:8080"));
1561 assert!(public_url_is_non_loopback("https://feather-reader.com"));
1562 assert!(public_url_is_non_loopback("http://203.0.113.5"));
1563 }
1564}