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