Skip to main content

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}