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