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. |
15//! | `FEATHERREADER_RETENTION_DAYS`| `90`                    | Prune read, unstarred entries older than this. |
16//! | `FEATHERREADER_PROXY_IMAGES` | `false`                  | Proxy feed images so reader IPs aren't leaked to feed hosts. |
17//! | `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. |
18//! | `FEATHERREADER_MAX_SUBS_PER_DID` | `500`                | Per-DID subscription cap. |
19//! | `FEATHERREADER_MAX_FEEDS`    | `10000`                  | Global distinct-feed ceiling. |
20//! | `FEATHERREADER_MAX_ENTRIES_PER_FEED` | `2000`           | Per-feed retained-entry cap (newest N). |
21//! | `FEATHERREADER_DB_SIZE_WATERMARK_BYTES` | `2 GiB`       | Above this the poller stops fetching new content (0 disables). |
22//! | `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. |
23//! | `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. |
24//! | `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). |
25//!
26//! The atproto OAuth sidecar (`@atproto/oauth-client-node`) is configured with a
27//! second small block — the base URL the Rust server reaches it on and the shared
28//! secret gating its internal API (see [`SidecarConfig`]):
29//!
30//! | Variable                       | Default                   | Meaning |
31//! |--------------------------------|---------------------------|---------|
32//! | `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`). |
33//! | `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. |
34//! | `SIDECAR_INTERNAL_SECRET`      | *(dev fallback)*          | Shared `X-Internal-Secret` for the sidecar's `/internal/*` API. |
35//! | `FEATHERREADER_COOKIE_SECRET`  | *(dev fallback)*          | HMAC key used to sign the session cookie. |
36//! | `FEATHERREADER_DEV_DID`        | *(unset)*                 | When set, a request with no session cookie acts as this DID (local runs without the sidecar). |
37//!
38//! `FEATHERREADER_BIND` also accepts the design's `FEATHERREADER_ADDR` spelling
39//! as a fallback for compatibility.
40
41use std::env;
42use std::net::SocketAddr;
43use std::path::PathBuf;
44use std::time::Duration;
45
46use anyhow::{Context, Result};
47
48/// Fully-resolved server configuration, materialized once at startup.
49#[derive(Debug, Clone)]
50pub struct Config {
51    /// The socket address the HTTP server binds to.
52    pub bind: SocketAddr,
53    /// Filesystem path to the SQLite cache/database file.
54    pub db_path: PathBuf,
55    /// The externally-reachable base URL (used to build the atproto OAuth
56    /// callback and client-metadata URLs). No trailing slash.
57    pub public_url: String,
58    /// Optional login allow-list of atproto DIDs. Empty means the instance is
59    /// open to any atproto identity that can log in.
60    pub allowed_dids: Vec<String>,
61    /// The default per-feed poll interval.
62    pub poll_interval: Duration,
63    /// Retention window: read, unstarred entries older than this are pruned.
64    pub retention_days: u32,
65    /// Whether to proxy feed images through the server (privacy vs. bandwidth).
66    pub proxy_images: bool,
67    /// Closed-beta seat cap: the maximum number of DIDs that may hold beta
68    /// access at once (redeeming an invite fails with `CapacityFull` past this).
69    /// From `FEATHERREADER_BETA_CAP`, default 100.
70    pub beta_cap: i64,
71    /// The reverse-proxy header the rate limiter TRUSTS for the real client IP,
72    /// e.g. `Fly-Client-IP` (bare Fly) or `CF-Connecting-IP` (Cloudflare). When
73    /// set, ONLY this header is consulted — never the spoofable multi-hop
74    /// `X-Forwarded-For` chain — and it falls back to the socket peer if the
75    /// header is absent/unparseable. Unset (the default) trusts the socket peer
76    /// only, which is correct for a direct bind with no proxy in front.
77    /// From `FEATHERREADER_TRUSTED_IP_HEADER`.
78    pub trusted_ip_header: Option<String>,
79    /// Per-DID subscription cap. A DID may hold at most this many subscriptions;
80    /// `add_subscription` rejects over it and `import_opml` trims to it. Bounds
81    /// the storage/poller blast radius of one account on a small box.
82    /// From `FEATHERREADER_MAX_SUBS_PER_DID`, default 500.
83    pub max_subs_per_did: i64,
84    /// Global ceiling on distinct feeds in the shared cache. A new feed is
85    /// refused once the `feeds` table holds this many rows (existing feeds still
86    /// poll). From `FEATHERREADER_MAX_FEEDS`, default 10_000.
87    pub max_feeds_global: i64,
88    /// Cap on how many entries are retained per feed on insert — the newest N by
89    /// published date; older rows are pruned in the same transaction so one
90    /// firehose feed can't fill the disk. From `FEATHERREADER_MAX_ENTRIES_PER_FEED`,
91    /// default 2_000.
92    pub max_entries_per_feed: i64,
93    /// DB-size watermark, in bytes. Above it the background poller stops fetching
94    /// new content (and logs an alert) so the `$3.50 box` can't be filled to a
95    /// crash. `0` disables the watermark. From `FEATHERREADER_DB_SIZE_WATERMARK_BYTES`,
96    /// default 2 GiB.
97    pub db_size_watermark_bytes: i64,
98    /// The atproto OAuth sidecar wiring (base URL + shared internal secret).
99    pub sidecar: SidecarConfig,
100    /// HMAC key used to sign the session cookie. In production this MUST be set
101    /// (`FEATHERREADER_COOKIE_SECRET`); a stable dev fallback is used otherwise
102    /// so local runs work without configuration.
103    pub cookie_secret: String,
104    /// Optional dev-only DID: when set, a request with no valid session cookie
105    /// is served as this DID (local runs without the OAuth sidecar). Unset in a
106    /// real deployment — no session then means "logged out".
107    pub dev_did: Option<String>,
108    /// Base URL of the atproto handle resolver (`com.atproto.identity.resolveHandle`),
109    /// no trailing slash. Used by the pre-handshake beta gate to turn a submitted
110    /// handle into a DID so an existing seat can be honored on a cookie-less first
111    /// login. Defaults to [`crate::atproto::DEFAULT_RESOLVER_HOST`]. From
112    /// `FEATHERREADER_RESOLVER_HOST`.
113    pub resolver_base: String,
114    /// Shared bearer secret gating the headless bot mint endpoint (`POST
115    /// /bot/claims`), sent by the follow→invite bot as `X-Bot-Secret`. When empty
116    /// the endpoint is DISABLED (503) — a bot can't mint. Like the cookie/sidecar
117    /// secrets it MUST be set on a production-like instance (fail-loud at boot);
118    /// on a loopback/dev instance it stays unset so `/bot/claims` is simply off
119    /// until an operator opts in. From `FEATHERREADER_BOT_SECRET`.
120    pub bot_secret: Option<String>,
121    /// TTL (seconds) for a claim invite code minted by `POST /bot/claims`. The
122    /// bot delivers the claim link asynchronously (a public skeet), so this is a
123    /// generous window — the admin-mint browser flow's 30-minute TTL would expire
124    /// before the follower ever taps the link. From `FEATHERREADER_CLAIM_TTL_SECS`,
125    /// default 14 days.
126    pub claim_ttl_secs: i64,
127}
128
129/// Configuration for the atproto OAuth sidecar (`@atproto/oauth-client-node`).
130///
131/// The Rust server drives the sidecar over two surfaces:
132/// * the **public** `${public_url}/login` URL the browser is redirected to (and
133///   which anchors the sidecar's OAuth `client_id`/`redirect_uri`), and
134/// * the **internal** `${internal_url}/internal/*` API (session lookup + the authed
135///   `com.atproto.repo.*` proxy), gated by the shared [`internal_secret`] sent as
136///   the `X-Internal-Secret` header.
137///
138/// The two URLs differ in a split deployment (public = the edge origin, internal =
139/// a loopback address the app reaches the sidecar on); they collapse to the same
140/// value in single-URL local dev.
141#[derive(Debug, Clone)]
142pub struct SidecarConfig {
143    /// Public base URL of the sidecar (no trailing slash), e.g.
144    /// `https://feather-reader.com/oauth`. Anchors the browser `/login` redirect.
145    pub public_url: String,
146    /// Loopback base URL for the sidecar's `/internal/*` API (no trailing slash),
147    /// e.g. `http://127.0.0.1:8081`. Defaults to `public_url` in single-URL dev.
148    pub internal_url: String,
149    /// Shared secret for the sidecar's internal API (`X-Internal-Secret`).
150    pub internal_secret: String,
151}
152
153/// The sidecar's own dev fallback for the shared secret (matches the sidecar's
154/// `dev-internal-secret-change-me`) so a fully-local dev stack works untouched.
155const DEV_INTERNAL_SECRET: &str = "dev-internal-secret-change-me";
156
157/// The default sidecar base URL — loopback, matching the sidecar's own default.
158const DEFAULT_SIDECAR_URL: &str = "http://127.0.0.1:8081";
159
160/// A stable, clearly-marked dev cookie key. Overridden by
161/// `FEATHERREADER_COOKIE_SECRET` in any real deployment.
162const DEV_COOKIE_SECRET: &str = "featherreader-dev-cookie-secret-change-me";
163
164/// Default TTL for a bot-minted claim code: 14 days. Long enough that an
165/// asynchronously-delivered claim link (a public follow-back skeet) is still
166/// live when the follower taps it.
167const DEFAULT_CLAIM_TTL_SECS: i64 = 14 * 24 * 60 * 60;
168
169impl Default for SidecarConfig {
170    fn default() -> Self {
171        Self {
172            public_url: DEFAULT_SIDECAR_URL.to_string(),
173            internal_url: DEFAULT_SIDECAR_URL.to_string(),
174            internal_secret: DEV_INTERNAL_SECRET.to_string(),
175        }
176    }
177}
178
179impl SidecarConfig {
180    /// The sidecar's public `/login` URL (the browser redirect target).
181    pub fn login_url(&self) -> String {
182        format!("{}/login", self.public_url)
183    }
184
185    /// The sidecar's `/internal/session/:id` URL (loopback internal API).
186    pub fn session_url(&self, session_id: &str) -> String {
187        format!("{}/internal/session/{}", self.internal_url, session_id)
188    }
189
190    /// The sidecar's `/internal/repo` URL (the authed `com.atproto.repo.*` proxy).
191    pub fn repo_url(&self) -> String {
192        format!("{}/internal/repo", self.internal_url)
193    }
194}
195
196impl Default for Config {
197    fn default() -> Self {
198        Self {
199            // Loopback-only by default: safe for a first run; front with a
200            // reverse proxy / tunnel to expose it.
201            bind: SocketAddr::from(([127, 0, 0, 1], 8080)),
202            db_path: PathBuf::from("featherreader.db"),
203            public_url: "http://localhost:8080".to_string(),
204            allowed_dids: Vec::new(),
205            poll_interval: Duration::from_secs(3600),
206            retention_days: 90,
207            proxy_images: false,
208            beta_cap: 100,
209            trusted_ip_header: None,
210            max_subs_per_did: 500,
211            max_feeds_global: 10_000,
212            max_entries_per_feed: 2_000,
213            db_size_watermark_bytes: 2 * 1024 * 1024 * 1024,
214            sidecar: SidecarConfig::default(),
215            cookie_secret: DEV_COOKIE_SECRET.to_string(),
216            dev_did: None,
217            resolver_base: crate::atproto::DEFAULT_RESOLVER_HOST.to_string(),
218            bot_secret: None,
219            claim_ttl_secs: DEFAULT_CLAIM_TTL_SECS,
220        }
221    }
222}
223
224impl Config {
225    /// Build a [`Config`] from the process environment, falling back to the
226    /// defaults above for anything unset. Returns an error only when a *present*
227    /// variable fails to parse — an unset variable is never an error.
228    pub fn from_env() -> Result<Self> {
229        let defaults = Config::default();
230
231        // FEATHERREADER_BIND (preferred) or FEATHERREADER_ADDR (design alias).
232        let bind = match env_opt("FEATHERREADER_BIND").or_else(|| env_opt("FEATHERREADER_ADDR")) {
233            Some(raw) => raw
234                .parse::<SocketAddr>()
235                .with_context(|| format!("FEATHERREADER_BIND: invalid socket address {raw:?}"))?,
236            None => defaults.bind,
237        };
238
239        let db_path = env_opt("FEATHERREADER_DB")
240            .map(PathBuf::from)
241            .unwrap_or(defaults.db_path);
242
243        let public_url = env_opt("FEATHERREADER_PUBLIC_URL")
244            // Normalize away a trailing slash so callers can join paths cleanly.
245            .map(|u| u.trim_end_matches('/').to_string())
246            .unwrap_or(defaults.public_url);
247
248        let allowed_dids = env_opt("FEATHERREADER_ALLOWED_DIDS")
249            .map(|raw| {
250                raw.split(',')
251                    .map(str::trim)
252                    .filter(|s| !s.is_empty())
253                    .map(str::to_string)
254                    .collect::<Vec<_>>()
255            })
256            .unwrap_or(defaults.allowed_dids);
257
258        let poll_interval = match env_opt("FEATHERREADER_POLL_INTERVAL") {
259            Some(raw) => {
260                let secs: u64 = raw.parse().with_context(|| {
261                    format!("FEATHERREADER_POLL_INTERVAL: expected seconds, got {raw:?}")
262                })?;
263                Duration::from_secs(secs)
264            }
265            None => defaults.poll_interval,
266        };
267
268        let retention_days = match env_opt("FEATHERREADER_RETENTION_DAYS") {
269            Some(raw) => raw.parse().with_context(|| {
270                format!("FEATHERREADER_RETENTION_DAYS: expected an integer, got {raw:?}")
271            })?,
272            None => defaults.retention_days,
273        };
274
275        let proxy_images = match env_opt("FEATHERREADER_PROXY_IMAGES") {
276            Some(raw) => parse_bool(&raw).with_context(|| {
277                format!("FEATHERREADER_PROXY_IMAGES: expected a boolean, got {raw:?}")
278            })?,
279            None => defaults.proxy_images,
280        };
281
282        let beta_cap = match env_opt("FEATHERREADER_BETA_CAP") {
283            Some(raw) => raw.parse().with_context(|| {
284                format!("FEATHERREADER_BETA_CAP: expected an integer, got {raw:?}")
285            })?,
286            None => defaults.beta_cap,
287        };
288
289        // Trusted client-IP header for the rate limiter. Normalized to lowercase
290        // (header lookup is case-insensitive); unset => trust only the socket peer.
291        let trusted_ip_header =
292            env_opt("FEATHERREADER_TRUSTED_IP_HEADER").map(|h| h.trim().to_ascii_lowercase());
293
294        let max_subs_per_did = match env_opt("FEATHERREADER_MAX_SUBS_PER_DID") {
295            Some(raw) => raw.parse().with_context(|| {
296                format!("FEATHERREADER_MAX_SUBS_PER_DID: expected an integer, got {raw:?}")
297            })?,
298            None => defaults.max_subs_per_did,
299        };
300
301        let max_feeds_global = match env_opt("FEATHERREADER_MAX_FEEDS") {
302            Some(raw) => raw.parse().with_context(|| {
303                format!("FEATHERREADER_MAX_FEEDS: expected an integer, got {raw:?}")
304            })?,
305            None => defaults.max_feeds_global,
306        };
307
308        let max_entries_per_feed = match env_opt("FEATHERREADER_MAX_ENTRIES_PER_FEED") {
309            Some(raw) => raw.parse().with_context(|| {
310                format!("FEATHERREADER_MAX_ENTRIES_PER_FEED: expected an integer, got {raw:?}")
311            })?,
312            None => defaults.max_entries_per_feed,
313        };
314
315        let db_size_watermark_bytes = match env_opt("FEATHERREADER_DB_SIZE_WATERMARK_BYTES") {
316            Some(raw) => raw.parse().with_context(|| {
317                format!("FEATHERREADER_DB_SIZE_WATERMARK_BYTES: expected an integer, got {raw:?}")
318            })?,
319            None => defaults.db_size_watermark_bytes,
320        };
321
322        // --- atproto OAuth sidecar --------------------------------------
323        let sidecar_url = env_opt("SIDECAR_PUBLIC_URL")
324            .map(|u| u.trim_end_matches('/').to_string())
325            .unwrap_or_else(|| defaults.sidecar.public_url.clone());
326        // The internal API is reached over loopback in a split deployment; it
327        // falls back to the resolved public URL so single-URL local dev works.
328        let internal_url = env_opt("SIDECAR_INTERNAL_URL")
329            .map(|u| u.trim_end_matches('/').to_string())
330            .unwrap_or_else(|| sidecar_url.clone());
331        let internal_secret = env_opt("SIDECAR_INTERNAL_SECRET")
332            .unwrap_or_else(|| defaults.sidecar.internal_secret.clone());
333        let sidecar = SidecarConfig {
334            public_url: sidecar_url,
335            internal_url,
336            internal_secret,
337        };
338
339        let cookie_secret = env_opt("FEATHERREADER_COOKIE_SECRET")
340            .unwrap_or_else(|| defaults.cookie_secret.clone());
341
342        // A dev DID is opt-in: only present when explicitly configured, so a real
343        // deployment never silently falls back to a shared identity.
344        let dev_did = env_opt("FEATHERREADER_DEV_DID");
345
346        let resolver_base = env_opt("FEATHERREADER_RESOLVER_HOST")
347            .map(|u| u.trim_end_matches('/').to_string())
348            .unwrap_or(defaults.resolver_base);
349
350        // Shared bot secret gating `POST /bot/claims`. Unset => the endpoint is
351        // disabled; `validate_secrets` still fail-loud rejects the *published dev
352        // default* / a too-short value on a production-like instance.
353        let bot_secret = env_opt("FEATHERREADER_BOT_SECRET");
354
355        let claim_ttl_secs = match env_opt("FEATHERREADER_CLAIM_TTL_SECS") {
356            Some(raw) => {
357                let secs: i64 = raw.parse().with_context(|| {
358                    format!("FEATHERREADER_CLAIM_TTL_SECS: expected seconds, got {raw:?}")
359                })?;
360                validate_claim_ttl(secs)?
361            }
362            None => defaults.claim_ttl_secs,
363        };
364
365        let config = Self {
366            bind,
367            db_path,
368            public_url,
369            allowed_dids,
370            poll_interval,
371            retention_days,
372            proxy_images,
373            beta_cap,
374            trusted_ip_header,
375            max_subs_per_did,
376            max_feeds_global,
377            max_entries_per_feed,
378            db_size_watermark_bytes,
379            sidecar,
380            cookie_secret,
381            dev_did,
382            resolver_base,
383            bot_secret,
384            claim_ttl_secs,
385        };
386
387        // FAIL LOUD: a non-loopback (public) instance must never fall back to the
388        // repo-published dev secrets — those are known to any attacker, who could
389        // then forge a session cookie offline. Refuse to boot instead.
390        config.validate_secrets()?;
391
392        Ok(config)
393    }
394
395    /// Whether this instance is "production-like" and therefore MUST have strong,
396    /// non-default secrets. True when `FEATHERREADER_ENV=prod`, or when either the
397    /// bind address or the public URL points at a non-loopback host — i.e. the
398    /// server is reachable by someone other than the local operator.
399    fn is_prod_like(&self) -> bool {
400        if env_opt("FEATHERREADER_ENV")
401            .map(|v| v.eq_ignore_ascii_case("prod") || v.eq_ignore_ascii_case("production"))
402            .unwrap_or(false)
403        {
404            return true;
405        }
406        // A non-loopback bind (incl. 0.0.0.0, reachable off-box) is public; so is
407        // a public_url that resolves to a non-loopback host.
408        !self.bind.ip().is_loopback() || public_url_is_non_loopback(&self.public_url)
409    }
410
411    /// Enforce the secret policy for a production-like instance. On a
412    /// loopback/dev instance the dev fallbacks are kept for convenience; on a
413    /// public one each secret must be explicitly set, not equal to its published
414    /// dev constant, and at least 32 bytes. Returns `Err` (refuse boot) otherwise.
415    fn validate_secrets(&self) -> Result<()> {
416        if !self.is_prod_like() {
417            return Ok(());
418        }
419        check_secret(
420            "FEATHERREADER_COOKIE_SECRET",
421            &self.cookie_secret,
422            DEV_COOKIE_SECRET,
423        )?;
424        check_secret(
425            "SIDECAR_INTERNAL_SECRET",
426            &self.sidecar.internal_secret,
427            DEV_INTERNAL_SECRET,
428        )?;
429        // The bot secret is OPTIONAL (unset => `/bot/claims` disabled, which is a
430        // safe default). But if it IS set on a production-like instance it must be
431        // strong — a weak/short shared bearer would let anyone mint claim codes.
432        if let Some(bot_secret) = &self.bot_secret {
433            check_secret("FEATHERREADER_BOT_SECRET", bot_secret, "")?;
434        }
435        // Split-deploy footgun: on a production-like instance, if the sidecar's
436        // INTERNAL base equals its PUBLIC base and that base is non-loopback, the
437        // Rust server would send the `X-Internal-Secret` + all session/repo
438        // traffic to the PUBLIC edge URL over the network (SIDECAR_INTERNAL_URL
439        // was left unset and fell back to SIDECAR_PUBLIC_URL). The canonical
440        // container bakes SIDECAR_INTERNAL_URL to loopback; a bare-binary deploy
441        // must set it explicitly. Refuse to boot rather than leak the secret.
442        if self.sidecar.internal_url == self.sidecar.public_url
443            && public_url_is_non_loopback(&self.sidecar.public_url)
444        {
445            anyhow::bail!(
446                "SIDECAR_INTERNAL_URL is unset (defaulting to the public \
447                 SIDECAR_PUBLIC_URL '{}') on a production-like instance: the internal \
448                 API secret and all session/repo traffic would traverse the public \
449                 network. Set SIDECAR_INTERNAL_URL to the sidecar's loopback/private \
450                 address (e.g. http://127.0.0.1:8081).",
451                self.sidecar.public_url
452            );
453        }
454        Ok(())
455    }
456
457    /// Whether the given atproto DID is permitted to log in. When no allow-list
458    /// is configured the instance is open, so every DID is allowed.
459    pub fn did_allowed(&self, did: &str) -> bool {
460        self.allowed_dids.is_empty() || self.allowed_dids.iter().any(|d| d == did)
461    }
462
463    /// The admin-bootstrap seed for the closed-beta gate: the DIDs that get a
464    /// `beta_access` seat automatically (via [`crate::store::ensure_seed`]) so a
465    /// fresh instance always has at least the operator(s) inside the gate and
466    /// able to mint invite codes.
467    ///
468    /// Reuses `ALLOWED_DIDS` as the seed source — the same "these are the people
469    /// I trust on this instance" concept — so operators don't configure the list
470    /// twice. Returns a borrowed slice (empty when the instance is open / no
471    /// allow-list is set, in which case there is nothing to seed).
472    pub fn admin_seed_dids(&self) -> &[String] {
473        &self.allowed_dids
474    }
475}
476
477/// Minimum length (in bytes) for a production secret. 32 bytes = 256 bits, the
478/// floor for an HMAC-SHA256 key with a full-strength security margin.
479const MIN_SECRET_BYTES: usize = 32;
480
481/// Enforce that a production secret is set, not the published dev constant, and
482/// long enough. Returns a fail-loud `Err` naming the offending variable.
483fn check_secret(var: &str, value: &str, dev_constant: &str) -> Result<()> {
484    if value.is_empty() || value == dev_constant {
485        anyhow::bail!(
486            "{var} is unset or still the published dev default on a non-loopback (production) \
487             instance; refusing to boot. Set {var} to a random secret of at least \
488             {MIN_SECRET_BYTES} bytes."
489        );
490    }
491    if value.len() < MIN_SECRET_BYTES {
492        anyhow::bail!(
493            "{var} is too short ({} bytes) for a production instance; it must be at least \
494             {MIN_SECRET_BYTES} bytes.",
495            value.len()
496        );
497    }
498    Ok(())
499}
500
501/// Whether a `public_url` points at a non-loopback host. A parse failure or a
502/// missing host is treated as non-loopback (fail closed toward "public").
503fn public_url_is_non_loopback(public_url: &str) -> bool {
504    match url::Url::parse(public_url) {
505        Ok(u) => match u.host() {
506            Some(url::Host::Domain(d)) => {
507                !(d.eq_ignore_ascii_case("localhost") || d.eq_ignore_ascii_case("localhost."))
508            }
509            Some(url::Host::Ipv4(ip)) => !ip.is_loopback(),
510            Some(url::Host::Ipv6(ip)) => !ip.is_loopback(),
511            None => true,
512        },
513        Err(_) => true,
514    }
515}
516
517/// Validate a parsed `FEATHERREADER_CLAIM_TTL_SECS`: it MUST be positive. The
518/// bot-minted claim link is delivered ASYNCHRONOUSLY (a public skeet), so a
519/// non-positive TTL mints an instantly-expired, dead link the follower can never
520/// redeem. Fail loud at boot rather than silently hand out broken links.
521fn validate_claim_ttl(secs: i64) -> Result<i64> {
522    if secs <= 0 {
523        anyhow::bail!(
524            "FEATHERREADER_CLAIM_TTL_SECS must be > 0 (got {secs}); a non-positive TTL yields \
525             instantly-expired, dead claim links"
526        );
527    }
528    Ok(secs)
529}
530
531/// Read an env var, treating an empty value the same as unset.
532fn env_opt(key: &str) -> Option<String> {
533    match env::var(key) {
534        Ok(v) if !v.trim().is_empty() => Some(v),
535        _ => None,
536    }
537}
538
539/// Parse a permissive boolean: `1/true/yes/on` vs `0/false/no/off`
540/// (case-insensitive).
541fn parse_bool(raw: &str) -> Result<bool> {
542    match raw.trim().to_ascii_lowercase().as_str() {
543        "1" | "true" | "yes" | "on" => Ok(true),
544        "0" | "false" | "no" | "off" => Ok(false),
545        other => anyhow::bail!("not a boolean: {other:?}"),
546    }
547}
548
549#[cfg(test)]
550mod tests {
551    use super::*;
552
553    #[test]
554    fn defaults_are_sane() {
555        let c = Config::default();
556        assert_eq!(c.bind.port(), 8080);
557        assert_eq!(c.poll_interval, Duration::from_secs(3600));
558        assert_eq!(c.retention_days, 90);
559        assert!(!c.proxy_images);
560        assert!(c.allowed_dids.is_empty());
561        assert_eq!(c.beta_cap, 100);
562        // Hardening caps default to safe, non-zero bounds; no trusted proxy header.
563        assert!(c.trusted_ip_header.is_none());
564        assert_eq!(c.max_subs_per_did, 500);
565        assert_eq!(c.max_feeds_global, 10_000);
566        assert_eq!(c.max_entries_per_feed, 2_000);
567        assert_eq!(c.db_size_watermark_bytes, 2 * 1024 * 1024 * 1024);
568    }
569
570    #[test]
571    fn admin_seed_reuses_allowed_dids() {
572        let open = Config::default();
573        assert!(open.admin_seed_dids().is_empty());
574        let gated = Config {
575            allowed_dids: vec!["did:plc:me".to_string(), "did:plc:you".to_string()],
576            ..Config::default()
577        };
578        assert_eq!(gated.admin_seed_dids(), &["did:plc:me", "did:plc:you"]);
579    }
580
581    #[test]
582    fn open_instance_allows_any_did() {
583        let c = Config::default();
584        assert!(c.did_allowed("did:plc:anything"));
585    }
586
587    #[test]
588    fn allow_list_gates_dids() {
589        let c = Config {
590            allowed_dids: vec!["did:plc:me".to_string()],
591            ..Config::default()
592        };
593        assert!(c.did_allowed("did:plc:me"));
594        assert!(!c.did_allowed("did:plc:stranger"));
595    }
596
597    #[test]
598    fn parse_bool_accepts_common_spellings() {
599        assert!(parse_bool("Yes").unwrap());
600        assert!(!parse_bool("OFF").unwrap());
601        assert!(parse_bool("maybe").is_err());
602    }
603
604    #[test]
605    fn loopback_instance_keeps_dev_fallback_secrets() {
606        // Default config is loopback + dev secrets: must be allowed to boot.
607        let c = Config::default();
608        assert!(!c.is_prod_like());
609        assert!(c.validate_secrets().is_ok());
610    }
611
612    #[test]
613    fn public_bind_with_dev_cookie_secret_refuses_boot() {
614        let c = Config {
615            bind: SocketAddr::from(([0, 0, 0, 0], 8080)),
616            ..Config::default()
617        };
618        assert!(c.is_prod_like());
619        // Still carries the published dev cookie secret → must fail loud.
620        let err = c.validate_secrets().unwrap_err().to_string();
621        assert!(err.contains("FEATHERREADER_COOKIE_SECRET"), "{err}");
622    }
623
624    #[test]
625    fn public_bind_with_short_secret_refuses_boot() {
626        let c = Config {
627            bind: SocketAddr::from(([203, 0, 113, 5], 8080)),
628            cookie_secret: "too-short".to_string(),
629            ..Config::default()
630        };
631        assert!(c.is_prod_like());
632        assert!(c.validate_secrets().is_err());
633    }
634
635    #[test]
636    fn public_bind_with_dev_sidecar_secret_refuses_boot() {
637        let c = Config {
638            bind: SocketAddr::from(([203, 0, 113, 5], 8080)),
639            // Strong cookie secret, but sidecar secret still the dev default.
640            cookie_secret: "x".repeat(48),
641            ..Config::default()
642        };
643        let err = c.validate_secrets().unwrap_err().to_string();
644        assert!(err.contains("SIDECAR_INTERNAL_SECRET"), "{err}");
645    }
646
647    #[test]
648    fn public_bind_with_strong_secrets_boots() {
649        let c = Config {
650            bind: SocketAddr::from(([203, 0, 113, 5], 8080)),
651            cookie_secret: "a".repeat(48),
652            sidecar: SidecarConfig {
653                public_url: DEFAULT_SIDECAR_URL.to_string(),
654                internal_url: DEFAULT_SIDECAR_URL.to_string(),
655                internal_secret: "b".repeat(48),
656            },
657            ..Config::default()
658        };
659        assert!(c.is_prod_like());
660        assert!(c.validate_secrets().is_ok());
661    }
662
663    #[test]
664    fn public_sidecar_url_without_internal_url_refuses_boot() {
665        // Strong secrets, but the sidecar internal URL fell back to a
666        // non-loopback public URL → the internal secret would go over the wire.
667        let c = Config {
668            bind: SocketAddr::from(([203, 0, 113, 5], 8080)),
669            cookie_secret: "a".repeat(48),
670            sidecar: SidecarConfig {
671                public_url: "https://feather-reader.com/oauth".to_string(),
672                internal_url: "https://feather-reader.com/oauth".to_string(),
673                internal_secret: "b".repeat(48),
674            },
675            ..Config::default()
676        };
677        let err = c.validate_secrets().unwrap_err().to_string();
678        assert!(err.contains("SIDECAR_INTERNAL_URL"), "{err}");
679    }
680
681    #[test]
682    fn public_sidecar_url_with_loopback_internal_url_boots() {
683        // Same public sidecar URL, but an explicit loopback internal URL: safe.
684        let c = Config {
685            bind: SocketAddr::from(([203, 0, 113, 5], 8080)),
686            cookie_secret: "a".repeat(48),
687            sidecar: SidecarConfig {
688                public_url: "https://feather-reader.com/oauth".to_string(),
689                internal_url: "http://127.0.0.1:8081".to_string(),
690                internal_secret: "b".repeat(48),
691            },
692            ..Config::default()
693        };
694        assert!(c.validate_secrets().is_ok());
695    }
696
697    #[test]
698    fn bot_secret_defaults_unset() {
699        let c = Config::default();
700        assert!(c.bot_secret.is_none());
701        assert_eq!(c.claim_ttl_secs, DEFAULT_CLAIM_TTL_SECS);
702    }
703
704    #[test]
705    fn claim_ttl_must_be_positive() {
706        // A positive TTL passes through unchanged.
707        assert_eq!(validate_claim_ttl(3600).unwrap(), 3600);
708        assert_eq!(
709            validate_claim_ttl(DEFAULT_CLAIM_TTL_SECS).unwrap(),
710            DEFAULT_CLAIM_TTL_SECS
711        );
712        // Zero and negative are rejected loudly (they mint dead, expired links).
713        for bad in [0, -1, -1209600] {
714            let err = validate_claim_ttl(bad).unwrap_err().to_string();
715            assert!(err.contains("FEATHERREADER_CLAIM_TTL_SECS"), "{err}");
716            assert!(err.contains("must be > 0"), "{err}");
717        }
718    }
719
720    #[test]
721    fn loopback_instance_allows_weak_bot_secret() {
722        // On a dev/loopback instance the bot secret isn't validated (the whole
723        // secret policy is skipped), so even a short one is accepted.
724        let c = Config {
725            bot_secret: Some("short".to_string()),
726            ..Config::default()
727        };
728        assert!(!c.is_prod_like());
729        assert!(c.validate_secrets().is_ok());
730    }
731
732    #[test]
733    fn public_bind_with_short_bot_secret_refuses_boot() {
734        let c = Config {
735            bind: SocketAddr::from(([203, 0, 113, 5], 8080)),
736            cookie_secret: "a".repeat(48),
737            sidecar: SidecarConfig {
738                public_url: DEFAULT_SIDECAR_URL.to_string(),
739                internal_url: DEFAULT_SIDECAR_URL.to_string(),
740                internal_secret: "b".repeat(48),
741            },
742            bot_secret: Some("too-short".to_string()),
743            ..Config::default()
744        };
745        let err = c.validate_secrets().unwrap_err().to_string();
746        assert!(err.contains("FEATHERREADER_BOT_SECRET"), "{err}");
747    }
748
749    #[test]
750    fn public_bind_with_strong_bot_secret_boots() {
751        let c = Config {
752            bind: SocketAddr::from(([203, 0, 113, 5], 8080)),
753            cookie_secret: "a".repeat(48),
754            sidecar: SidecarConfig {
755                public_url: DEFAULT_SIDECAR_URL.to_string(),
756                internal_url: DEFAULT_SIDECAR_URL.to_string(),
757                internal_secret: "b".repeat(48),
758            },
759            bot_secret: Some("c".repeat(48)),
760            ..Config::default()
761        };
762        assert!(c.validate_secrets().is_ok());
763    }
764
765    #[test]
766    fn public_bind_with_unset_bot_secret_boots() {
767        // An unset bot secret is fine on prod (the endpoint is just disabled).
768        let c = Config {
769            bind: SocketAddr::from(([203, 0, 113, 5], 8080)),
770            cookie_secret: "a".repeat(48),
771            sidecar: SidecarConfig {
772                public_url: DEFAULT_SIDECAR_URL.to_string(),
773                internal_url: DEFAULT_SIDECAR_URL.to_string(),
774                internal_secret: "b".repeat(48),
775            },
776            bot_secret: None,
777            ..Config::default()
778        };
779        assert!(c.validate_secrets().is_ok());
780    }
781
782    #[test]
783    fn public_url_non_loopback_detection() {
784        assert!(!public_url_is_non_loopback("http://localhost:8080"));
785        assert!(!public_url_is_non_loopback("http://127.0.0.1:8080"));
786        assert!(!public_url_is_non_loopback("http://[::1]:8080"));
787        assert!(public_url_is_non_loopback("https://feather-reader.com"));
788        assert!(public_url_is_non_loopback("http://203.0.113.5"));
789    }
790}