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}