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