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}