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_STARTUP_DELAY_SECS` | unset | Shortens every background loop's delay before its FIRST tick (30/45/60/90 s, and 5 min for the relay probe). A **ceiling**: a larger value changes nothing and says so in the log. For dev loops and integration runs; production wants the built-in values. Read in `scheduler.rs`, listed here because this table is where an operator looks. |
16//! | `FEATHERREADER_RETENTION_HARD_DAYS` | `180` | Absolute ceiling: entries older than this go regardless of starred/unread. The bound that keeps one reader's pins from filling a shared cache and stalling the poller. `0` removes the ceiling — the ONLY bound on pinned entries, so `0` here means the cache is unbounded. Must be STRICTLY GREATER than the window below, or `0`: a ceiling inside the window would delete the rows the window spares, so it cannot be applied, and startup REFUSES the pair rather than silently running unbounded. |
17//! | `FEATHERREADER_RETENTION_DAYS`| `14`                    | Evict READ, UNSTARRED entries older than this. Starred and unread entries survive this window but not the hard ceiling above. `0` disables this rolling window ONLY; the ceiling still applies. Set BOTH to `0` for no eviction at all. |
18//! | `FEATHERREADER_PUBLICATION_RETENTION_DAYS` | `3650` | Absolute ceiling for entries of a kind the rolling window does not apply to — a standard.site publication. Publications are bounded by COUNT (`max_entries_per_feed`) instead of by age, because measurement says a 14-day window stores NOTHING from a real publication: the newest documents on three of them were 109 to 241 days old. This is the "not immortal" backstop, not the space bound. `0` disables it. |
19//! | `FEATHERREADER_PROXY_IMAGES` | `false`                  | Proxy feed images so reader IPs aren't leaked to feed hosts. |
20//! | `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. |
21//! | `FEATHERREADER_MAX_SUBS_PER_DID` | `500`                | Per-DID subscription cap. |
22//! | `FEATHERREADER_MAX_FEEDS`    | `10000`                  | Global distinct-feed ceiling. |
23//! | `FEATHERREADER_MAX_ENTRIES_PER_FEED` | `2000`           | Per-feed retained-entry cap (newest N). |
24//! | `FEATHERREADER_DB_SIZE_WATERMARK_BYTES` | `2 GiB`       | Above this the poller stops fetching new content (0 disables). |
25//! | `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. |
26//! | `FEATHERREADER_BOT_SECRET`   | *(unset = `/bot/claims` disabled)* | Shared bearer secret (`X-Bot-Secret`) gating the headless follow→invite bot's mint endpoint `POST /bot/claims`. Unset ⇒ endpoint returns 503. MUST be set (strong) on a production-like instance if the bot is used. |
27//! | `FEATHERREADER_CLAIM_TTL_SECS` | `1209600` (14 days)   | TTL for a bot-minted claim invite code — long, since the claim link is delivered asynchronously (a public skeet). |
28//! | `FEATHERREADER_RELAY_HOSTS`  | `relay1.us-west.bsky.network,relay1.us-east.bsky.network` | Relays queried for the network adoption count. Bare hosts or full URLs. Setting it to the **empty string** names no relays and so disables the probe (unset ⇒ the defaults above; the two are deliberately distinguished). |
29//! | `FEATHERREADER_ADOPTION_INTERVAL_SECS` | `86400` (24h)  | Adoption-probe cadence (±10% jitter). `0` disables the probe. |
30//! | `FEATHERREADER_SHOW_ADOPTION` | `false`                 | Render the one-line adoption fact on `/about`. |
31//!
32//! The **cutover switch** and the Rust-native OAuth client it selects:
33//!
34//! | Variable                          | Default             | Meaning |
35//! |-----------------------------------|---------------------|---------|
36//! | `FEATHERREADER_REPO_BACKEND`      | `sidecar`           | Which implementation serves `com.atproto.repo.*`: `sidecar` or `rust`. An unrecognised value FAILS startup rather than defaulting, since a silent fallback would make every side-by-side measurement a comparison of the sidecar with itself. The container entrypoint reads the same variable to install the matching Caddy OAuth routing — the two cannot share `/oauth/callback`, so they must agree. |
37//! | `FEATHERREADER_STANDARD_SITE`     | `false`             | Whether an `at://…/site.standard.publication/…` subscription may be **stored** — one arriving via OPML import or a record another client wrote. The subscribe form cannot take one yet, and nothing polls one: `at://` rows are skipped by kind, not failed (see `feed::FeedKind::POLLABLE`). Both land with the standard.site reader. |
38//! | `FEATHERREADER_OAUTH_KEY_PATH`    | `oauth-signing-key.json` | The client's ES256 signing key, encrypted at rest in the SAME format the sidecar writes so one file serves both and a rollback finds what it expects. |
39//! | `FEATHERREADER_OAUTH_ENCRYPTION_KEY` | *(unset = plaintext)* | At-rest encryption for the signing key and stored sessions. Generate it, do not choose it — `openssl rand -hex 32`. The value is stretched with a single SHA-256 (pinned for byte-compatibility with the sidecar's format), so its entropy is the ceiling, and the adversary this protects against is someone holding a volume snapshot with all the time in the world. |
40//! | `FEATHERREADER_PLC_DIRECTORY`     | `https://plc.directory` | Directory used to resolve `did:plc` documents. |
41//! | `FEATHERREADER_OAUTH_SCOPE`       | `atproto transition:generic` | Scope requested at login. Part of the dev `client_id`, so changing it changes the client's identity in dev. |
42//!
43//! The atproto OAuth sidecar (`@atproto/oauth-client-node`) is configured with a
44//! second small block — the base URL the Rust server reaches it on and the shared
45//! secret gating its internal API (see [`SidecarConfig`]):
46//!
47//! | Variable                       | Default                   | Meaning |
48//! |--------------------------------|---------------------------|---------|
49//! | `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`). |
50//! | `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. |
51//! | `SIDECAR_INTERNAL_SECRET`      | *(dev fallback)*          | Shared `X-Internal-Secret` for the sidecar's `/internal/*` API. |
52//! | `FEATHERREADER_COOKIE_SECRET`  | *(dev fallback)*          | HMAC key used to sign the session cookie. |
53//! | `FEATHERREADER_DEV_DID`        | *(unset)*                 | When set, a request with no session cookie acts as this DID (local runs without the sidecar). |
54//!
55//! `FEATHERREADER_BIND` also accepts the design's `FEATHERREADER_ADDR` spelling
56//! as a fallback for compatibility.
57
58use std::env;
59use std::net::SocketAddr;
60use std::path::PathBuf;
61use std::time::Duration;
62
63use anyhow::{Context, Result};
64
65/// Fully-resolved server configuration, materialized once at startup.
66#[derive(Debug, Clone)]
67pub struct Config {
68    /// The socket address the HTTP server binds to.
69    pub bind: SocketAddr,
70    /// Filesystem path to the SQLite cache/database file.
71    pub db_path: PathBuf,
72    /// The externally-reachable base URL (used to build the atproto OAuth
73    /// callback and client-metadata URLs). No trailing slash.
74    pub public_url: String,
75    /// Optional login allow-list of atproto DIDs. Empty means the instance is
76    /// open to any atproto identity that can log in.
77    pub allowed_dids: Vec<String>,
78    /// The default per-feed poll interval.
79    pub poll_interval: Duration,
80    /// Cache eviction window, in days: a READ, UNSTARRED entry older than this
81    /// is dropped from the local cache. Starred and still-unread entries are
82    /// kept past this window — but NOT indefinitely: see `retention_hard_days`,
83    /// which is the bound. The PDS holds the reader's choices, and the entry
84    /// CONTENT lives only here and at the origin feed, which usually serves just
85    /// its last few dozen items.
86    ///
87    /// Two weeks by default. The cache exists to render a feed list quickly,
88    /// not to archive the web.
89    pub retention_days: u32,
90    /// Absolute cache ceiling, in days. Entries older than this are dropped
91    /// REGARDLESS of starred or unread state.
92    ///
93    /// This is the bound, and sparing would remove it without one. "Mark
94    /// unread" is a one-click control and `entries` is shared across every
95    /// reader, so an unbounded exception lets one person pin rows permanently —
96    /// and because the poller stops entirely once the database crosses
97    /// `db_size_watermark_bytes`, with the retention DELETE as its only release
98    /// valve, those pins could stop polling for everyone.
99    ///
100    /// Losing a starred entry here is survivable: the saved record stays in the
101    /// reader's PDS and renders as a link.
102    pub retention_hard_days: u32,
103    /// Absolute ceiling, in days, for entries of a kind the rolling window does
104    /// **not** apply to — a standard.site publication today. `0` disables it.
105    ///
106    /// **Ten years, and the reason is that age is the wrong policy here at all.**
107    /// Measured on 2026-09-27, reading three real publications through
108    /// `standard_site::fetch`: the newest document Standard.site offered was 131
109    /// days old, Annotated's 109 (oldest 373), minus listens' 241. Under the
110    /// 14-day window every one of them stored **zero** rows — a successful poll
111    /// and an empty feed. Long-form publishing is not news-paced, so a
112    /// publication is bounded by COUNT (`max_entries_per_feed`, the newest N plus
113    /// up to N starred) rather than by age.
114    ///
115    /// This number is therefore not a space bound; the per-feed trim is. It is
116    /// the guarantee that "not aged out" does not become "immortal": the trim
117    /// only runs when a poll stores something, so entries of a feed nobody polls
118    /// any more would otherwise never be reaped. Ten years is longer than the
119    /// protocol itself, so it cannot truncate an archive that exists today, while
120    /// still being a real bound rather than none.
121    ///
122    /// Per-publication retention on the reader's own PDS will choose inside this
123    /// ceiling; the instance's number stays the upper bound.
124    pub publication_retention_days: u32,
125    /// Whether to proxy feed images through the server (privacy vs. bandwidth).
126    pub proxy_images: bool,
127    /// Closed-beta seat cap: the maximum number of DIDs that may hold beta
128    /// access at once (redeeming an invite fails with `CapacityFull` past this).
129    /// From `FEATHERREADER_BETA_CAP`, default 100.
130    pub beta_cap: i64,
131    /// The reverse-proxy header the rate limiter TRUSTS for the real client IP,
132    /// e.g. `Fly-Client-IP` (bare Fly) or `CF-Connecting-IP` (Cloudflare). When
133    /// set, ONLY this header is consulted — never the spoofable multi-hop
134    /// `X-Forwarded-For` chain — and it falls back to the socket peer if the
135    /// header is absent/unparseable. Unset (the default) trusts the socket peer
136    /// only, which is correct for a direct bind with no proxy in front.
137    /// From `FEATHERREADER_TRUSTED_IP_HEADER`.
138    pub trusted_ip_header: Option<String>,
139    /// Per-DID subscription cap. A DID may hold at most this many subscriptions;
140    /// `add_subscription` rejects over it and `import_opml` trims to it. Bounds
141    /// the storage/poller blast radius of one account on a small box.
142    /// From `FEATHERREADER_MAX_SUBS_PER_DID`, default 500.
143    pub max_subs_per_did: i64,
144    /// Global ceiling on distinct feeds in the shared cache. A new feed is
145    /// refused once the `feeds` table holds this many rows (existing feeds still
146    /// poll). From `FEATHERREADER_MAX_FEEDS`, default 10_000.
147    pub max_feeds_global: i64,
148    /// Cap on how many entries are retained per feed on insert — the newest N by
149    /// published date; older rows are pruned in the same transaction so one
150    /// firehose feed can't fill the disk. From `FEATHERREADER_MAX_ENTRIES_PER_FEED`,
151    /// default 2_000.
152    pub max_entries_per_feed: i64,
153    /// DB-size watermark, in bytes. Above it the background poller stops fetching
154    /// new content (and logs an alert) so the `$3.50 box` can't be filled to a
155    /// crash. `0` disables the watermark. From `FEATHERREADER_DB_SIZE_WATERMARK_BYTES`,
156    /// default 2 GiB.
157    pub db_size_watermark_bytes: i64,
158    /// The atproto OAuth sidecar wiring (base URL + shared internal secret).
159    pub sidecar: SidecarConfig,
160    /// The Rust-native OAuth client's own wiring. Read whatever the backend, so
161    /// a misconfiguration is caught at startup rather than at the moment the
162    /// switch is thrown.
163    pub oauth: OauthConfig,
164    /// HMAC key used to sign the session cookie. In production this MUST be set
165    /// (`FEATHERREADER_COOKIE_SECRET`); a stable dev fallback is used otherwise
166    /// so local runs work without configuration.
167    pub cookie_secret: String,
168    /// Optional dev-only DID: when set, a request with no valid session cookie
169    /// is served as this DID (local runs without the OAuth sidecar). Unset in a
170    /// real deployment — no session then means "logged out".
171    pub dev_did: Option<String>,
172    /// Which repo implementation serves `com.atproto.repo.*` — the cutover
173    /// switch. Defaults to the sidecar, so deploying the Rust client changes
174    /// nothing until this is set deliberately.
175    pub repo_backend: crate::metrics::Backend,
176    /// Whether an `at://` standard.site publication subscription may be
177    /// **stored** — via OPML import or a record another client wrote. The
178    /// subscribe form cannot take one yet: the add path must fetch what is
179    /// pasted and nothing fetches `at://`, so it refuses with its own message.
180    /// From `FEATHERREADER_STANDARD_SITE`, default **off**.
181    ///
182    /// **This flag does not gate polling; nothing does, because nothing polls
183    /// an `at://` row.** An earlier version of this comment claimed one flag
184    /// gated both. It did not — nothing outside the storable guards read it.
185    /// Why `at://` rows are skipped rather than failed, and where that one
186    /// decision lives, is documented once on [`crate::feed::FeedKind::POLLABLE`].
187    pub standard_site: bool,
188    /// Base URL of the atproto handle resolver (`com.atproto.identity.resolveHandle`),
189    /// no trailing slash. Used by the pre-handshake beta gate to turn a submitted
190    /// handle into a DID so an existing seat can be honored on a cookie-less first
191    /// login. Defaults to [`crate::atproto::DEFAULT_RESOLVER_HOST`]. From
192    /// `FEATHERREADER_RESOLVER_HOST`.
193    pub resolver_base: String,
194    /// Shared bearer secret gating the headless bot mint endpoint (`POST
195    /// /bot/claims`), sent by the follow→invite bot as `X-Bot-Secret`. When empty
196    /// the endpoint is DISABLED (503) — a bot can't mint. Like the cookie/sidecar
197    /// secrets it MUST be set on a production-like instance (fail-loud at boot);
198    /// on a loopback/dev instance it stays unset so `/bot/claims` is simply off
199    /// until an operator opts in. From `FEATHERREADER_BOT_SECRET`.
200    pub bot_secret: Option<String>,
201    /// TTL (seconds) for a claim invite code minted by `POST /bot/claims`. The
202    /// bot delivers the claim link asynchronously (a public skeet), so this is a
203    /// generous window — the admin-mint browser flow's 30-minute TTL would expire
204    /// before the follower ever taps the link. From `FEATHERREADER_CLAIM_TTL_SECS`,
205    /// default 14 days.
206    pub claim_ttl_secs: i64,
207    /// Relay bases queried for the network adoption count, as normalized origin
208    /// URLs (scheme + host, no trailing slash) — the fetch layer is handed
209    /// something [`crate::net`] can scheme-allow-list rather than being asked to
210    /// guess. An empty list disables the probe, and `FEATHERREADER_RELAY_HOSTS=`
211    /// (present, empty) is how an operator asks for exactly that — distinct from
212    /// leaving the variable unset, which takes the defaults. Bare hostnames are
213    /// accepted and normalized to `https://…`.
214    pub relay_hosts: Vec<String>,
215    /// Entries of `FEATHERREADER_RELAY_HOSTS` that were rejected as unusable, in
216    /// `"value" (reason)` form. Parsing happens before `init_tracing`, so these
217    /// are carried here and warned about by the adoption probe task instead of
218    /// being lost — or, as they were previously, aborting boot.
219    pub relay_host_errors: Vec<String>,
220    /// How often the adoption probe runs. [`Duration::ZERO`] (the env value `0`)
221    /// DISABLES it. From `FEATHERREADER_ADOPTION_INTERVAL_SECS`, default 24 h.
222    pub adoption_interval: Duration,
223    /// Render the one-line adoption fact on `/about`. Default **false**: a count
224    /// of `1` reads as a status claim rather than a fact, and the honest home for
225    /// it today is the log. From `FEATHERREADER_SHOW_ADOPTION`.
226    pub show_adoption: bool,
227}
228
229/// Configuration for the atproto OAuth sidecar (`@atproto/oauth-client-node`).
230///
231/// The Rust server drives the sidecar over two surfaces:
232/// * the **public** `${public_url}/login` URL the browser is redirected to (and
233///   which anchors the sidecar's OAuth `client_id`/`redirect_uri`), and
234/// * the **internal** `${internal_url}/internal/*` API (session lookup + the authed
235///   `com.atproto.repo.*` proxy), gated by the shared [`SidecarConfig::internal_secret`] sent as
236///   the `X-Internal-Secret` header.
237///
238/// The two URLs differ in a split deployment (public = the edge origin, internal =
239/// a loopback address the app reaches the sidecar on); they collapse to the same
240/// value in single-URL local dev.
241#[derive(Debug, Clone)]
242pub struct SidecarConfig {
243    /// Public base URL of the sidecar (no trailing slash), e.g.
244    /// `https://feather-reader.com/oauth`. Anchors the browser `/login` redirect.
245    pub public_url: String,
246    /// Loopback base URL for the sidecar's `/internal/*` API (no trailing slash),
247    /// e.g. `http://127.0.0.1:8081`. Defaults to `public_url` in single-URL dev.
248    pub internal_url: String,
249    /// Shared secret for the sidecar's internal API (`X-Internal-Secret`).
250    pub internal_secret: String,
251}
252
253/// Wiring for the Rust-native OAuth client.
254#[derive(Debug, Clone)]
255pub struct OauthConfig {
256    /// Path to the client's ES256 signing key. Encrypted at rest with
257    /// `encryption_key`, in the SAME `enc.v1` format the sidecar writes, so the
258    /// two can share one file and a rollback finds the key it expects.
259    pub key_path: PathBuf,
260    /// Passphrase for the at-rest encryption of the signing key and the stored
261    /// sessions. `None` leaves them in PLAINTEXT, which `validate_secrets`
262    /// refuses on a production-like instance when the Rust backend is selected
263    /// — that is the only configuration in which these tables are written.
264    pub encryption_key: Option<String>,
265    /// The PLC directory used to resolve `did:plc` documents.
266    pub plc_directory: String,
267    /// The OAuth scope requested at login. Part of the dev `client_id`, so
268    /// changing it changes the client's identity in dev.
269    pub scope: String,
270}
271
272/// The default PLC directory — the canonical one operated by Bluesky.
273const DEFAULT_PLC_DIRECTORY: &str = "https://plc.directory";
274
275/// The scope the reader needs: `atproto` for identity, `transition:generic` for
276/// the `com.atproto.repo.*` writes. Matches the sidecar's.
277const DEFAULT_OAUTH_SCOPE: &str = "atproto transition:generic";
278
279impl Default for OauthConfig {
280    fn default() -> Self {
281        Self {
282            key_path: PathBuf::from("oauth-signing-key.json"),
283            encryption_key: None,
284            plc_directory: DEFAULT_PLC_DIRECTORY.to_string(),
285            scope: DEFAULT_OAUTH_SCOPE.to_string(),
286        }
287    }
288}
289
290/// The sidecar's own dev fallback for the shared secret (matches the sidecar's
291/// `dev-internal-secret-change-me`) so a fully-local dev stack works untouched.
292const DEV_INTERNAL_SECRET: &str = "dev-internal-secret-change-me";
293
294/// The default sidecar base URL — loopback, matching the sidecar's own default.
295const DEFAULT_SIDECAR_URL: &str = "http://127.0.0.1:8081";
296
297/// A stable, clearly-marked dev cookie key. Overridden by
298/// `FEATHERREADER_COOKIE_SECRET` in any real deployment.
299const DEV_COOKIE_SECRET: &str = "featherreader-dev-cookie-secret-change-me";
300
301/// Default TTL for a bot-minted claim code: 14 days. Long enough that an
302/// asynchronously-delivered claim link (a public follow-back skeet) is still
303/// live when the follower taps it.
304const DEFAULT_CLAIM_TTL_SECS: i64 = 14 * 24 * 60 * 60;
305
306/// Default adoption-probe cadence: once a day. One unauthenticated GET per relay
307/// per day is the entire network cost of the feature.
308const DEFAULT_ADOPTION_INTERVAL: Duration = Duration::from_secs(86_400);
309
310impl Default for SidecarConfig {
311    fn default() -> Self {
312        Self {
313            public_url: DEFAULT_SIDECAR_URL.to_string(),
314            internal_url: DEFAULT_SIDECAR_URL.to_string(),
315            internal_secret: DEV_INTERNAL_SECRET.to_string(),
316        }
317    }
318}
319
320impl SidecarConfig {
321    /// The sidecar's public `/login` URL (the browser redirect target).
322    pub fn login_url(&self) -> String {
323        format!("{}/login", self.public_url)
324    }
325
326    /// The sidecar's `/internal/session/:id` URL (loopback internal API).
327    pub fn session_url(&self, session_id: &str) -> String {
328        format!("{}/internal/session/{}", self.internal_url, session_id)
329    }
330
331    /// The sidecar's `/internal/repo` URL (the authed `com.atproto.repo.*` proxy).
332    pub fn repo_url(&self) -> String {
333        format!("{}/internal/repo", self.internal_url)
334    }
335}
336
337/// Parse the cutover switch. Unknown values are an ERROR rather than a silent
338/// fall back to the default: a typo in `FEATHERREADER_REPO_BACKEND=rsut` that
339/// quietly kept the sidecar live would make the whole comparison a measurement
340/// of the sidecar against itself.
341fn parse_repo_backend(raw: &str) -> Result<crate::metrics::Backend> {
342    match raw.trim() {
343        "sidecar" => Ok(crate::metrics::Backend::Sidecar),
344        "rust" => Ok(crate::metrics::Backend::Rust),
345        other => anyhow::bail!(
346            "FEATHERREADER_REPO_BACKEND: expected \"sidecar\" or \"rust\", got {other:?}"
347        ),
348    }
349}
350
351impl Default for Config {
352    fn default() -> Self {
353        Self {
354            // Loopback-only by default: safe for a first run; front with a
355            // reverse proxy / tunnel to expose it.
356            bind: SocketAddr::from(([127, 0, 0, 1], 8080)),
357            db_path: PathBuf::from("featherreader.db"),
358            public_url: "http://localhost:8080".to_string(),
359            allowed_dids: Vec::new(),
360            poll_interval: Duration::from_secs(3600),
361            retention_days: 14,
362            retention_hard_days: 180,
363            publication_retention_days: 3_650,
364            proxy_images: false,
365            beta_cap: 100,
366            trusted_ip_header: None,
367            max_subs_per_did: 500,
368            max_feeds_global: 10_000,
369            max_entries_per_feed: 2_000,
370            db_size_watermark_bytes: 2 * 1024 * 1024 * 1024,
371            sidecar: SidecarConfig::default(),
372            oauth: OauthConfig::default(),
373            cookie_secret: DEV_COOKIE_SECRET.to_string(),
374            // The sidecar stays the live path until the switch is thrown.
375            repo_backend: crate::metrics::Backend::Sidecar,
376            // Off by default. The flag gates STORING an at:// feed; polling is
377            // excluded by scheme in `due_feeds` regardless, until the
378            // standard.site reader is wired to the scheduler.
379            standard_site: false,
380            dev_did: None,
381            resolver_base: crate::atproto::DEFAULT_RESOLVER_HOST.to_string(),
382            bot_secret: None,
383            claim_ttl_secs: DEFAULT_CLAIM_TTL_SECS,
384            relay_host_errors: Vec::new(),
385            relay_hosts: crate::network::DEFAULT_RELAY_HOSTS
386                .iter()
387                .map(|h| format!("https://{h}"))
388                .collect(),
389            adoption_interval: DEFAULT_ADOPTION_INTERVAL,
390            show_adoption: false,
391        }
392    }
393}
394
395impl Config {
396    /// The retention window that applies to entries of `kind`, as
397    /// `(days, hard_days)` for [`crate::store::prune_old_entries`].
398    ///
399    /// **One home for the policy, because it has two halves that must agree.**
400    /// The sweep decides what to DELETE; `standard_site::ingest_floor` decides
401    /// what is even worth STORING, and it is written to mirror the sweep. If the
402    /// two disagree, the store gains rows the sweep deletes and the next poll
403    /// re-inserts — the resurrection cycle, which costs a reader their read state
404    /// once per window, forever. Both sides read this.
405    ///
406    /// An RSS feed gets the rolling window and the hard ceiling. A publication
407    /// gets **no rolling window** and the archive ceiling instead: measured, a
408    /// 14-day window stored zero rows from every real publication tried, because
409    /// their newest documents were 109 to 241 days old. See
410    /// [`Config::publication_retention_days`] and `feed::FeedKind::AGED`.
411    ///
412    /// Returning `0` for a publication's window is load-bearing rather than
413    /// incidental: `prune_old_entries` honours a ceiling when `days <= 0`, and
414    /// `ingest_floor` falls through to the ceiling on the same condition.
415    pub fn retention_for(&self, kind: crate::feed::FeedKind) -> (u32, u32) {
416        match kind {
417            crate::feed::FeedKind::Rss => (self.retention_days, self.retention_hard_days),
418            crate::feed::FeedKind::Publication => (0, self.publication_retention_days),
419        }
420    }
421
422    /// Build a [`Config`] from the process environment, falling back to the
423    /// defaults above for anything unset. Returns an error only when a *present*
424    /// variable fails to parse — an unset variable is never an error.
425    pub fn from_env() -> Result<Self> {
426        let defaults = Config::default();
427
428        // FEATHERREADER_BIND (preferred) or FEATHERREADER_ADDR (design alias).
429        let bind = match env_opt("FEATHERREADER_BIND").or_else(|| env_opt("FEATHERREADER_ADDR")) {
430            Some(raw) => raw
431                .parse::<SocketAddr>()
432                .with_context(|| format!("FEATHERREADER_BIND: invalid socket address {raw:?}"))?,
433            None => defaults.bind,
434        };
435
436        let db_path = env_opt("FEATHERREADER_DB")
437            .map(PathBuf::from)
438            .unwrap_or(defaults.db_path);
439
440        let public_url = env_opt("FEATHERREADER_PUBLIC_URL")
441            // Normalize away a trailing slash so callers can join paths cleanly.
442            .map(|u| u.trim_end_matches('/').to_string())
443            .unwrap_or(defaults.public_url);
444
445        let allowed_dids = env_opt("FEATHERREADER_ALLOWED_DIDS")
446            .map(|raw| {
447                raw.split(',')
448                    .map(str::trim)
449                    .filter(|s| !s.is_empty())
450                    .map(str::to_string)
451                    .collect::<Vec<_>>()
452            })
453            .unwrap_or(defaults.allowed_dids);
454
455        let poll_interval = match env_opt("FEATHERREADER_POLL_INTERVAL") {
456            Some(raw) => {
457                let secs: u64 = raw.parse().with_context(|| {
458                    format!("FEATHERREADER_POLL_INTERVAL: expected seconds, got {raw:?}")
459                })?;
460                Duration::from_secs(secs)
461            }
462            None => defaults.poll_interval,
463        };
464
465        let retention_hard_days = match env_opt("FEATHERREADER_RETENTION_HARD_DAYS") {
466            Some(raw) => raw.parse().with_context(|| {
467                format!("FEATHERREADER_RETENTION_HARD_DAYS: expected an integer, got {raw:?}")
468            })?,
469            None => defaults.retention_hard_days,
470        };
471
472        let retention_days = match env_opt("FEATHERREADER_RETENTION_DAYS") {
473            Some(raw) => raw.parse().with_context(|| {
474                format!("FEATHERREADER_RETENTION_DAYS: expected an integer, got {raw:?}")
475            })?,
476            None => defaults.retention_days,
477        };
478
479        let publication_retention_days = match env_opt("FEATHERREADER_PUBLICATION_RETENTION_DAYS") {
480            Some(raw) => raw.parse().with_context(|| {
481                format!(
482                    "FEATHERREADER_PUBLICATION_RETENTION_DAYS: expected an integer, got {raw:?}"
483                )
484            })?,
485            None => defaults.publication_retention_days,
486        };
487
488        let proxy_images = match env_opt("FEATHERREADER_PROXY_IMAGES") {
489            Some(raw) => parse_bool(&raw).with_context(|| {
490                format!("FEATHERREADER_PROXY_IMAGES: expected a boolean, got {raw:?}")
491            })?,
492            None => defaults.proxy_images,
493        };
494
495        let beta_cap = match env_opt("FEATHERREADER_BETA_CAP") {
496            Some(raw) => raw.parse().with_context(|| {
497                format!("FEATHERREADER_BETA_CAP: expected an integer, got {raw:?}")
498            })?,
499            None => defaults.beta_cap,
500        };
501
502        // Trusted client-IP header for the rate limiter. Normalized to lowercase
503        // (header lookup is case-insensitive); unset => trust only the socket peer.
504        let trusted_ip_header =
505            env_opt("FEATHERREADER_TRUSTED_IP_HEADER").map(|h| h.trim().to_ascii_lowercase());
506
507        let max_subs_per_did = match env_opt("FEATHERREADER_MAX_SUBS_PER_DID") {
508            Some(raw) => raw.parse().with_context(|| {
509                format!("FEATHERREADER_MAX_SUBS_PER_DID: expected an integer, got {raw:?}")
510            })?,
511            None => defaults.max_subs_per_did,
512        };
513
514        let max_feeds_global = match env_opt("FEATHERREADER_MAX_FEEDS") {
515            Some(raw) => raw.parse().with_context(|| {
516                format!("FEATHERREADER_MAX_FEEDS: expected an integer, got {raw:?}")
517            })?,
518            None => defaults.max_feeds_global,
519        };
520
521        let max_entries_per_feed = match env_opt("FEATHERREADER_MAX_ENTRIES_PER_FEED") {
522            Some(raw) => raw.parse().with_context(|| {
523                format!("FEATHERREADER_MAX_ENTRIES_PER_FEED: expected an integer, got {raw:?}")
524            })?,
525            None => defaults.max_entries_per_feed,
526        };
527
528        let db_size_watermark_bytes = match env_opt("FEATHERREADER_DB_SIZE_WATERMARK_BYTES") {
529            Some(raw) => raw.parse().with_context(|| {
530                format!("FEATHERREADER_DB_SIZE_WATERMARK_BYTES: expected an integer, got {raw:?}")
531            })?,
532            None => defaults.db_size_watermark_bytes,
533        };
534
535        // --- atproto OAuth sidecar --------------------------------------
536        let sidecar_url = env_opt("SIDECAR_PUBLIC_URL")
537            .map(|u| u.trim_end_matches('/').to_string())
538            .unwrap_or_else(|| defaults.sidecar.public_url.clone());
539        // The internal API is reached over loopback in a split deployment; it
540        // falls back to the resolved public URL so single-URL local dev works.
541        let internal_url = env_opt("SIDECAR_INTERNAL_URL")
542            .map(|u| u.trim_end_matches('/').to_string())
543            .unwrap_or_else(|| sidecar_url.clone());
544        let internal_secret = env_opt("SIDECAR_INTERNAL_SECRET")
545            .unwrap_or_else(|| defaults.sidecar.internal_secret.clone());
546        let sidecar = SidecarConfig {
547            public_url: sidecar_url,
548            internal_url,
549            internal_secret,
550        };
551
552        let cookie_secret = env_opt("FEATHERREADER_COOKIE_SECRET")
553            .unwrap_or_else(|| defaults.cookie_secret.clone());
554
555        // A dev DID is opt-in: only present when explicitly configured, so a real
556        // deployment never silently falls back to a shared identity.
557        let dev_did = env_opt("FEATHERREADER_DEV_DID");
558
559        let resolver_base = env_opt("FEATHERREADER_RESOLVER_HOST")
560            .map(|u| u.trim_end_matches('/').to_string())
561            .unwrap_or(defaults.resolver_base);
562
563        // Shared bot secret gating `POST /bot/claims`. Unset => the endpoint is
564        // disabled; `validate_secrets` still fail-loud rejects the *published dev
565        // default* / a too-short value on a production-like instance.
566        let bot_secret = env_opt("FEATHERREADER_BOT_SECRET");
567
568        let claim_ttl_secs = match env_opt("FEATHERREADER_CLAIM_TTL_SECS") {
569            Some(raw) => {
570                let secs: i64 = raw.parse().with_context(|| {
571                    format!("FEATHERREADER_CLAIM_TTL_SECS: expected seconds, got {raw:?}")
572                })?;
573                validate_claim_ttl(secs)?
574            }
575            None => defaults.claim_ttl_secs,
576        };
577
578        // Relay hosts for the adoption probe. Read with `env::var` and NOT with
579        // `env_opt`, which folds a present-but-empty value into `None` — i.e.
580        // straight back to the two Bluesky defaults. `FEATHERREADER_RELAY_HOSTS=`
581        // is the documented kill switch, so "set, and set to nothing" has to stay
582        // distinguishable from "not set".
583        let relay_hosts_raw = env::var("FEATHERREADER_RELAY_HOSTS").ok();
584        let (relay_hosts, relay_host_errors) =
585            parse_relay_hosts(relay_hosts_raw.as_deref(), defaults.relay_hosts);
586
587        // NOTE: parsed here, NOT via the scheduler's `env_duration_secs`, which
588        // maps `0` back to its default — that would silently turn the documented
589        // "0 disables" kill switch into "every 24 h".
590        let adoption_interval = match env_opt("FEATHERREADER_ADOPTION_INTERVAL_SECS") {
591            Some(raw) => {
592                let secs: u64 = raw.parse().with_context(|| {
593                    format!("FEATHERREADER_ADOPTION_INTERVAL_SECS: expected seconds, got {raw:?}")
594                })?;
595                Duration::from_secs(secs)
596            }
597            None => defaults.adoption_interval,
598        };
599
600        let oauth = OauthConfig {
601            key_path: env_opt("FEATHERREADER_OAUTH_KEY_PATH")
602                .map(PathBuf::from)
603                .unwrap_or(defaults.oauth.key_path),
604            encryption_key: env_opt("FEATHERREADER_OAUTH_ENCRYPTION_KEY"),
605            plc_directory: env_opt("FEATHERREADER_PLC_DIRECTORY")
606                .map(|u| u.trim_end_matches('/').to_string())
607                .unwrap_or(defaults.oauth.plc_directory),
608            scope: env_opt("FEATHERREADER_OAUTH_SCOPE").unwrap_or(defaults.oauth.scope),
609        };
610
611        let repo_backend = match env_opt("FEATHERREADER_REPO_BACKEND") {
612            Some(raw) => parse_repo_backend(&raw)?,
613            None => defaults.repo_backend,
614        };
615
616        let standard_site = parse_standard_site(
617            env_opt("FEATHERREADER_STANDARD_SITE").as_deref(),
618            defaults.standard_site,
619        )?;
620
621        let show_adoption = match env_opt("FEATHERREADER_SHOW_ADOPTION") {
622            Some(raw) => parse_bool(&raw).with_context(|| {
623                format!("FEATHERREADER_SHOW_ADOPTION: expected a boolean, got {raw:?}")
624            })?,
625            None => defaults.show_adoption,
626        };
627
628        let config = Self {
629            oauth,
630            repo_backend,
631            standard_site,
632            bind,
633            db_path,
634            public_url,
635            allowed_dids,
636            poll_interval,
637            retention_days,
638            retention_hard_days,
639            publication_retention_days,
640            proxy_images,
641            beta_cap,
642            trusted_ip_header,
643            max_subs_per_did,
644            max_feeds_global,
645            max_entries_per_feed,
646            db_size_watermark_bytes,
647            sidecar,
648            cookie_secret,
649            dev_did,
650            resolver_base,
651            bot_secret,
652            claim_ttl_secs,
653            relay_hosts,
654            relay_host_errors,
655            adoption_interval,
656            show_adoption,
657        };
658
659        // FAIL LOUD: a non-loopback (public) instance must never fall back to the
660        // repo-published dev secrets — those are known to any attacker, who could
661        // then forge a session cookie offline. Refuse to boot instead.
662        config.validate_secrets()?;
663        config.validate_retention()?;
664
665        Ok(config)
666    }
667
668    /// Refuse a retention pair where the ceiling is inside the window.
669    ///
670    /// `prune_old_entries` ignores a hard ceiling that is not strictly older than
671    /// the rolling window, because applying it would delete exactly the starred
672    /// and unread rows the window exists to spare. That refusal is right, but the
673    /// fallback it lands on — no ceiling at all — is the UNBOUNDED one, and the
674    /// only signal was a `warn!` emitted once per daily sweep.
675    ///
676    /// The configuration that reaches it is not exotic. An operator who wants a
677    /// bigger cache sets `FEATHERREADER_RETENTION_DAYS=365` and leaves
678    /// `RETENTION_HARD_DAYS` at its 180-day default; `180 <= 365`, so the ceiling
679    /// silently disappears. The shared `entries` table then has no bound on rows
680    /// a reader has pinned by starring or marking unread — and `poll_due_once`
681    /// stops polling for EVERY reader once the database crosses the size
682    /// watermark, with the retention DELETE as the only release valve. One
683    /// reader can hold that valve shut permanently.
684    ///
685    /// So this is a boot refusal, matching how `FEATHERREADER_REPO_BACKEND`
686    /// treats an unrecognised value: a contradictory setting fails startup rather
687    /// than being reinterpreted into the most destructive reading available.
688    /// Both knobs off (`0`/`0`) is still allowed — that is an explicit choice to
689    /// run unbounded, not an accident of changing one variable.
690    fn validate_retention(&self) -> anyhow::Result<()> {
691        let (days, hard) = (self.retention_days, self.retention_hard_days);
692        if hard > 0 && days > 0 && hard <= days {
693            anyhow::bail!(
694                "FEATHERREADER_RETENTION_HARD_DAYS ({hard}) must be strictly greater than \
695                 FEATHERREADER_RETENTION_DAYS ({days}), or 0 to disable the ceiling. A ceiling \
696                 inside the window cannot be applied — it would delete exactly the starred and \
697                 unread entries the window exists to spare — so it would be ignored, leaving \
698                 the shared cache with NO bound on entries readers have pinned. Raise the \
699                 ceiling above the window (the default pair is 14/180), or set it to 0 if you \
700                 genuinely want no ceiling."
701            );
702        }
703        Ok(())
704    }
705
706    /// Whether this instance is "production-like" and therefore MUST have strong,
707    /// non-default secrets. True when `FEATHERREADER_ENV=prod`, or when either the
708    /// bind address or the public URL points at a non-loopback host — i.e. the
709    /// server is reachable by someone other than the local operator.
710    fn is_prod_like(&self) -> bool {
711        if env_opt("FEATHERREADER_ENV")
712            .map(|v| v.eq_ignore_ascii_case("prod") || v.eq_ignore_ascii_case("production"))
713            .unwrap_or(false)
714        {
715            return true;
716        }
717        // A non-loopback bind (incl. 0.0.0.0, reachable off-box) is public; so is
718        // a public_url that resolves to a non-loopback host.
719        !self.bind.ip().is_loopback() || public_url_is_non_loopback(&self.public_url)
720    }
721
722    /// Enforce the secret policy for a production-like instance. On a
723    /// loopback/dev instance the dev fallbacks are kept for convenience; on a
724    /// public one each secret must be explicitly set, not equal to its published
725    /// dev constant, and at least 32 bytes. Returns `Err` (refuse boot) otherwise.
726    fn validate_secrets(&self) -> Result<()> {
727        if !self.is_prod_like() {
728            return Ok(());
729        }
730        check_secret(
731            "FEATHERREADER_COOKIE_SECRET",
732            &self.cookie_secret,
733            DEV_COOKIE_SECRET,
734        )?;
735        check_secret(
736            "SIDECAR_INTERNAL_SECRET",
737            &self.sidecar.internal_secret,
738            DEV_INTERNAL_SECRET,
739        )?;
740        // The bot secret is OPTIONAL (unset => `/bot/claims` disabled, which is a
741        // safe default). But if it IS set on a production-like instance it must be
742        // strong — a weak/short shared bearer would let anyone mint claim codes.
743        if let Some(bot_secret) = &self.bot_secret {
744            check_secret("FEATHERREADER_BOT_SECRET", bot_secret, "")?;
745        }
746        // With the Rust backend live, `oauth_session` holds every user's access
747        // token, refresh token and DPoP PRIVATE KEY. An unset encryption key
748        // makes the codec a no-op and leaves all three in the clear in SQLite —
749        // on the same mounted volume as the feed cache, and in every snapshot
750        // and backup of it. Gated on the backend because the sidecar path never
751        // writes these tables, and blocking a rollback over a key that path does
752        // not read would be the wrong failure.
753        if self.repo_backend == crate::metrics::Backend::Rust {
754            match self.oauth.encryption_key.as_deref() {
755                Some(key) => check_secret("FEATHERREADER_OAUTH_ENCRYPTION_KEY", key, "")?,
756                None => anyhow::bail!(
757                    "FEATHERREADER_REPO_BACKEND=rust on a production-like instance requires \
758                     FEATHERREADER_OAUTH_ENCRYPTION_KEY: without it every stored access token, \
759                     refresh token and DPoP private key is written to SQLite in plaintext. \
760                     Set it to a random secret of at least {MIN_SECRET_BYTES} bytes."
761                ),
762            }
763        }
764
765        // Split-deploy footgun: on a production-like instance, if the sidecar's
766        // INTERNAL base equals its PUBLIC base and that base is non-loopback, the
767        // Rust server would send the `X-Internal-Secret` + all session/repo
768        // traffic to the PUBLIC edge URL over the network (SIDECAR_INTERNAL_URL
769        // was left unset and fell back to SIDECAR_PUBLIC_URL). The canonical
770        // container bakes SIDECAR_INTERNAL_URL to loopback; a bare-binary deploy
771        // must set it explicitly. Refuse to boot rather than leak the secret.
772        if self.sidecar.internal_url == self.sidecar.public_url
773            && public_url_is_non_loopback(&self.sidecar.public_url)
774        {
775            anyhow::bail!(
776                "SIDECAR_INTERNAL_URL is unset (defaulting to the public \
777                 SIDECAR_PUBLIC_URL '{}') on a production-like instance: the internal \
778                 API secret and all session/repo traffic would traverse the public \
779                 network. Set SIDECAR_INTERNAL_URL to the sidecar's loopback/private \
780                 address (e.g. http://127.0.0.1:8081).",
781                self.sidecar.public_url
782            );
783        }
784        Ok(())
785    }
786
787    /// Whether the given atproto DID is permitted to log in. When no allow-list
788    /// is configured the instance is open, so every DID is allowed.
789    pub fn did_allowed(&self, did: &str) -> bool {
790        self.allowed_dids.is_empty() || self.allowed_dids.iter().any(|d| d == did)
791    }
792
793    /// The admin-bootstrap seed for the closed-beta gate: the DIDs that get a
794    /// `beta_access` seat automatically (via [`crate::store::ensure_seed`]) so a
795    /// fresh instance always has at least the operator(s) inside the gate and
796    /// able to mint invite codes.
797    ///
798    /// Reuses `ALLOWED_DIDS` as the seed source — the same "these are the people
799    /// I trust on this instance" concept — so operators don't configure the list
800    /// twice. Returns a borrowed slice (empty when the instance is open / no
801    /// allow-list is set, in which case there is nothing to seed).
802    pub fn admin_seed_dids(&self) -> &[String] {
803        &self.allowed_dids
804    }
805}
806
807/// Minimum length (in bytes) for a production secret. 32 bytes = 256 bits, the
808/// floor for an HMAC-SHA256 key with a full-strength security margin.
809const MIN_SECRET_BYTES: usize = 32;
810
811/// Enforce that a production secret is set, not the published dev constant, and
812/// long enough. Returns a fail-loud `Err` naming the offending variable.
813fn check_secret(var: &str, value: &str, dev_constant: &str) -> Result<()> {
814    if value.is_empty() || value == dev_constant {
815        anyhow::bail!(
816            "{var} is unset or still the published dev default on a non-loopback (production) \
817             instance; refusing to boot. Set {var} to a random secret of at least \
818             {MIN_SECRET_BYTES} bytes."
819        );
820    }
821    if value.len() < MIN_SECRET_BYTES {
822        anyhow::bail!(
823            "{var} is too short ({} bytes) for a production instance; it must be at least \
824             {MIN_SECRET_BYTES} bytes.",
825            value.len()
826        );
827    }
828    Ok(())
829}
830
831/// Whether a `public_url` points at a non-loopback host. A parse failure or a
832/// missing host is treated as non-loopback (fail closed toward "public").
833fn public_url_is_non_loopback(public_url: &str) -> bool {
834    match url::Url::parse(public_url) {
835        Ok(u) => match u.host() {
836            Some(url::Host::Domain(d)) => {
837                !(d.eq_ignore_ascii_case("localhost") || d.eq_ignore_ascii_case("localhost."))
838            }
839            Some(url::Host::Ipv4(ip)) => !ip.is_loopback(),
840            Some(url::Host::Ipv6(ip)) => !ip.is_loopback(),
841            None => true,
842        },
843        Err(_) => true,
844    }
845}
846
847/// Validate a parsed `FEATHERREADER_CLAIM_TTL_SECS`: it MUST be positive. The
848/// bot-minted claim link is delivered ASYNCHRONOUSLY (a public skeet), so a
849/// non-positive TTL mints an instantly-expired, dead link the follower can never
850/// redeem. Fail loud at boot rather than silently hand out broken links.
851fn validate_claim_ttl(secs: i64) -> Result<i64> {
852    if secs <= 0 {
853        anyhow::bail!(
854            "FEATHERREADER_CLAIM_TTL_SECS must be > 0 (got {secs}); a non-positive TTL yields \
855             instantly-expired, dead claim links"
856        );
857    }
858    Ok(secs)
859}
860
861/// Decide `FEATHERREADER_STANDARD_SITE` from its raw value; unset means
862/// `default`, which is [`Config::default`]'s so the value lives in one place.
863/// Pure so it can be tested without touching the process environment.
864fn parse_standard_site(raw: Option<&str>, default: bool) -> Result<bool> {
865    match raw {
866        Some(raw) => parse_bool(raw)
867            .with_context(|| format!("FEATHERREADER_STANDARD_SITE={raw:?} is not a boolean")),
868        None => Ok(default),
869    }
870}
871
872/// Read an env var, treating an empty value the same as unset.
873fn env_opt(key: &str) -> Option<String> {
874    match env::var(key) {
875        Ok(v) if !v.trim().is_empty() => Some(v),
876        _ => None,
877    }
878}
879
880/// Parse `FEATHERREADER_RELAY_HOSTS` into normalized relay origin URLs.
881///
882/// `raw` is `None` **only** when the variable is genuinely absent, in which case
883/// `defaults` wins. A *present* value — including `""`, `"   "`, or `","` —
884/// yields exactly the hosts it names, so an empty one yields an empty list and
885/// the probe never runs. That distinction is the whole point of this function
886/// existing rather than being inlined behind `env_opt`, which collapses
887/// present-but-empty into absent and so silently restored the two Bluesky relay
888/// defaults for an operator who had explicitly asked for none.
889///
890/// A *malformed* host is **dropped, not fatal**, and returned in the second
891/// element so the caller can surface it once logging exists.
892///
893/// This deliberately breaks the "a present-but-bad var fails loud" rule, because
894/// here that rule had a worse failure mode than the thing it was guarding:
895/// `Config::from_env` runs before `init_tracing` (`main.rs:38` vs `:41`), so a
896/// hard error is an unexplained non-zero exit, and `deploy/container-entrypoint.sh`
897/// turns that into a restart loop. A typo in an **optional metric's** host list
898/// would have taken the whole reader offline. `run_adoption_probe` already
899/// states the intended contract — "a typo'd relay host must disable an optional
900/// metric, never block boot" — and this makes it true.
901fn parse_relay_hosts(raw: Option<&str>, defaults: Vec<String>) -> (Vec<String>, Vec<String>) {
902    let Some(raw) = raw else {
903        return (defaults, Vec::new());
904    };
905    let mut hosts = Vec::new();
906    let mut rejected = Vec::new();
907    for h in raw.split(',').map(str::trim).filter(|s| !s.is_empty()) {
908        match crate::network::normalize_relay_host(h) {
909            Ok(host) => hosts.push(host),
910            Err(err) => rejected.push(format!("{h:?} ({err})")),
911        }
912    }
913    (hosts, rejected)
914}
915
916/// Parse a permissive boolean: `1/true/yes/on` vs `0/false/no/off`
917/// (case-insensitive).
918fn parse_bool(raw: &str) -> Result<bool> {
919    match raw.trim().to_ascii_lowercase().as_str() {
920        "1" | "true" | "yes" | "on" => Ok(true),
921        "0" | "false" | "no" | "off" => Ok(false),
922        other => anyhow::bail!("not a boolean: {other:?}"),
923    }
924}
925
926#[cfg(test)]
927mod tests {
928    use super::*;
929
930    /// A ceiling inside the window is refused at BOOT, not ignored at sweep time.
931    ///
932    /// `prune_old_entries` correctly refuses to apply such a ceiling — it would
933    /// delete exactly the starred and unread rows the window spares — but the
934    /// fallback is "no ceiling", which is the unbounded reading. The pair is
935    /// reachable by changing ONE variable: raise `RETENTION_DAYS` to 365 and the
936    /// default 180-day ceiling silently disappears.
937    #[test]
938    fn a_retention_ceiling_inside_the_window_is_refused_at_startup() {
939        let base = Config::default();
940        let with = |days: u32, hard: u32| Config {
941            retention_days: days,
942            retention_hard_days: hard,
943            ..base.clone()
944        };
945
946        // The one-variable footgun this exists for.
947        let err = with(365, 180)
948            .validate_retention()
949            .expect_err("365/180 must be refused");
950        let msg = err.to_string();
951        assert!(
952            msg.contains("RETENTION_HARD_DAYS"),
953            "unhelpful message: {msg}"
954        );
955        assert!(msg.contains("strictly greater"), "unhelpful message: {msg}");
956
957        // Equal is refused too — the two cutoffs coincide, so the ceiling would
958        // delete precisely what the window spares.
959        assert!(with(14, 14).validate_retention().is_err());
960        assert!(with(30, 7).validate_retention().is_err());
961
962        // Valid pairs.
963        assert!(
964            with(14, 180).validate_retention().is_ok(),
965            "the default pair"
966        );
967        assert!(
968            with(365, 400).validate_retention().is_ok(),
969            "a bigger cache with the ceiling raised to match"
970        );
971        // Ceiling deliberately off: allowed, because it is an explicit choice
972        // rather than a side effect of moving the window.
973        assert!(with(14, 0).validate_retention().is_ok());
974        // Window off, ceiling on: the T1.3 configuration.
975        assert!(with(0, 180).validate_retention().is_ok());
976        // Both off: unbounded, but explicitly so.
977        assert!(with(0, 0).validate_retention().is_ok());
978    }
979
980    #[test]
981    fn defaults_are_sane() {
982        let c = Config::default();
983        assert_eq!(c.bind.port(), 8080);
984        assert_eq!(c.poll_interval, Duration::from_secs(3600));
985        assert_eq!(c.retention_days, 14);
986        assert!(!c.proxy_images);
987        assert!(c.allowed_dids.is_empty());
988        assert_eq!(c.beta_cap, 100);
989        // Hardening caps default to safe, non-zero bounds; no trusted proxy header.
990        assert!(c.trusted_ip_header.is_none());
991        assert_eq!(c.max_subs_per_did, 500);
992        assert_eq!(c.max_feeds_global, 10_000);
993        assert_eq!(c.max_entries_per_feed, 2_000);
994        assert_eq!(c.db_size_watermark_bytes, 2 * 1024 * 1024 * 1024);
995        // The adoption probe ships on (one GET per relay per day) but its
996        // /about line ships off.
997        assert_eq!(
998            c.relay_hosts,
999            vec![
1000                "https://relay1.us-west.bsky.network".to_string(),
1001                "https://relay1.us-east.bsky.network".to_string(),
1002            ]
1003        );
1004        assert_eq!(c.adoption_interval, Duration::from_secs(86_400));
1005        assert!(!c.show_adoption);
1006    }
1007
1008    /// The default host list must be exactly what `RelayClient` accepts — i.e.
1009    /// already normalized, so a default boot needs no re-parse and cannot fail.
1010    #[test]
1011    fn default_relay_hosts_are_already_normalized() {
1012        for host in Config::default().relay_hosts {
1013            assert_eq!(crate::network::normalize_relay_host(&host).unwrap(), host);
1014        }
1015    }
1016
1017    fn relay_defaults() -> Vec<String> {
1018        Config::default().relay_hosts
1019    }
1020
1021    /// **Regression (v0.2.8):** `FEATHERREADER_RELAY_HOSTS=` (present, empty) is
1022    /// the documented kill switch and must yield NO relays. Routing it through
1023    /// `env_opt` collapsed empty into absent, restoring the two Bluesky defaults
1024    /// and probing them daily against the operator's explicit instruction.
1025    #[test]
1026    fn empty_relay_hosts_env_disables_the_probe() {
1027        for raw in ["", "   ", ",", " , ,\t"] {
1028            let (hosts, rejected) = parse_relay_hosts(Some(raw), relay_defaults());
1029            assert!(
1030                hosts.is_empty(),
1031                "FEATHERREADER_RELAY_HOSTS={raw:?} must name no relays, got {hosts:?}"
1032            );
1033            assert!(rejected.is_empty(), "an empty value is not a typo");
1034        }
1035    }
1036
1037    /// The other half of the same distinction: *unset* still takes the defaults.
1038    #[test]
1039    fn absent_relay_hosts_env_keeps_the_defaults() {
1040        assert_eq!(
1041            parse_relay_hosts(None, relay_defaults()).0,
1042            relay_defaults()
1043        );
1044    }
1045
1046    #[test]
1047    fn relay_hosts_env_is_split_trimmed_and_normalized() {
1048        assert_eq!(
1049            parse_relay_hosts(
1050                Some(" relay.example , https://other.example/ ,"),
1051                Vec::new()
1052            )
1053            .0,
1054            vec![
1055                "https://relay.example".to_string(),
1056                "https://other.example".to_string(),
1057            ]
1058        );
1059    }
1060
1061    /// **Regression (v0.2.8 review):** a typo used to abort `Config::from_env`,
1062    /// and because config is parsed before `init_tracing` that surfaced as an
1063    /// unexplained exit — which `container-entrypoint.sh` turns into a restart
1064    /// loop. A bad host in an OPTIONAL metric's list must never take the reader
1065    /// offline: drop it, keep the good ones, and hand the operator the reason so
1066    /// the probe task can warn.
1067    #[test]
1068    fn malformed_relay_host_is_dropped_not_fatal() {
1069        let (hosts, rejected) =
1070            parse_relay_hosts(Some("relay.example,wss://relay.example"), Vec::new());
1071        assert_eq!(hosts, vec!["https://relay.example".to_string()]);
1072        assert_eq!(rejected.len(), 1, "the bad entry is reported, not silent");
1073        assert!(rejected[0].contains("wss://relay.example"), "{rejected:?}");
1074    }
1075
1076    /// Every entry bad ⇒ no hosts ⇒ the probe disables itself, still no panic
1077    /// and still no boot failure.
1078    #[test]
1079    fn all_relay_hosts_malformed_disables_the_probe_without_failing() {
1080        let (hosts, rejected) =
1081            parse_relay_hosts(Some("wss://a.example, ftp://b.example"), relay_defaults());
1082        assert!(hosts.is_empty());
1083        assert_eq!(rejected.len(), 2);
1084    }
1085
1086    /// **Plaintext tokens must not be deployable.**
1087    ///
1088    /// With the Rust backend live, `oauth_session` holds every user's access
1089    /// token, refresh token and DPoP PRIVATE KEY. Without an encryption key the
1090    /// codec is a no-op and all three sit in the clear in SQLite — on the same
1091    /// mounted volume as the feed cache, in every snapshot and backup of it.
1092    ///
1093    /// This was found by reading a real session row during the live test: the
1094    /// stored access token began `eyJ0eXAiOiJh`, i.e. a bare JWT. The doc
1095    /// comment on `OauthConfig::encryption_key` already CLAIMED this was
1096    /// refused; it was not.
1097    #[test]
1098    fn a_production_rust_backend_refuses_to_boot_without_an_encryption_key() {
1099        let cfg = Config {
1100            repo_backend: crate::metrics::Backend::Rust,
1101            public_url: "https://feather-reader.com".into(),
1102            cookie_secret: "a-long-enough-production-cookie-secret-value".into(),
1103            sidecar: SidecarConfig {
1104                internal_secret: "a-long-enough-production-internal-secret".into(),
1105                internal_url: "http://127.0.0.1:8081".into(),
1106                ..SidecarConfig::default()
1107            },
1108            oauth: OauthConfig {
1109                encryption_key: None,
1110                ..OauthConfig::default()
1111            },
1112            ..Config::default()
1113        };
1114        let err = cfg
1115            .validate_secrets()
1116            .expect_err("plaintext tokens must not boot in production");
1117        let rendered = format!("{err:#}");
1118        assert!(
1119            rendered.contains("FEATHERREADER_OAUTH_ENCRYPTION_KEY"),
1120            "the error must name the variable to set: {rendered}"
1121        );
1122    }
1123
1124    /// The SIDECAR backend is unaffected: it stores nothing in these tables, and
1125    /// blocking a rollback over a key that path never reads would be the wrong
1126    /// failure.
1127    #[test]
1128    fn the_sidecar_backend_boots_without_an_oauth_encryption_key() {
1129        let cfg = Config {
1130            repo_backend: crate::metrics::Backend::Sidecar,
1131            public_url: "https://feather-reader.com".into(),
1132            cookie_secret: "a-long-enough-production-cookie-secret-value".into(),
1133            sidecar: SidecarConfig {
1134                internal_secret: "a-long-enough-production-internal-secret".into(),
1135                internal_url: "http://127.0.0.1:8081".into(),
1136                ..SidecarConfig::default()
1137            },
1138            oauth: OauthConfig {
1139                encryption_key: None,
1140                ..OauthConfig::default()
1141            },
1142            ..Config::default()
1143        };
1144        assert!(cfg.validate_secrets().is_ok());
1145    }
1146
1147    /// A weak key is refused on the same terms as every other secret — a short
1148    /// passphrase is stretched into an AES key, so its entropy is the ceiling.
1149    #[test]
1150    fn a_weak_oauth_encryption_key_is_refused_in_production() {
1151        let cfg = Config {
1152            repo_backend: crate::metrics::Backend::Rust,
1153            public_url: "https://feather-reader.com".into(),
1154            cookie_secret: "a-long-enough-production-cookie-secret-value".into(),
1155            sidecar: SidecarConfig {
1156                internal_secret: "a-long-enough-production-internal-secret".into(),
1157                internal_url: "http://127.0.0.1:8081".into(),
1158                ..SidecarConfig::default()
1159            },
1160            oauth: OauthConfig {
1161                encryption_key: Some("short".into()),
1162                ..OauthConfig::default()
1163            },
1164            ..Config::default()
1165        };
1166        assert!(cfg.validate_secrets().is_err());
1167    }
1168
1169    /// **A typo must fail loudly.**
1170    ///
1171    /// `FEATHERREADER_REPO_BACKEND=rsut` falling back to the default would leave
1172    /// the sidecar serving every request while the operator believed the Rust
1173    /// path was live. Every number in the comparison would then be the sidecar
1174    /// measured against itself, and the cutover would look flawless right up
1175    /// until the flag was removed.
1176    #[test]
1177    fn an_unknown_repo_backend_is_an_error_rather_than_a_silent_default() {
1178        let err = parse_repo_backend("rsut").expect_err("a typo must not be ignored");
1179        let rendered = format!("{err:#}");
1180        assert!(
1181            rendered.contains("rsut"),
1182            "the message must name the bad value: {rendered}"
1183        );
1184        assert!(rendered.contains("sidecar") && rendered.contains("rust"));
1185    }
1186
1187    /// Both spellings parse, and surrounding whitespace (a stray newline in a
1188    /// compose file or secret) does not change the backend.
1189    #[test]
1190    fn the_two_backends_parse_including_stray_whitespace() {
1191        assert_eq!(
1192            parse_repo_backend("sidecar").unwrap(),
1193            crate::metrics::Backend::Sidecar
1194        );
1195        assert_eq!(
1196            parse_repo_backend("rust").unwrap(),
1197            crate::metrics::Backend::Rust
1198        );
1199        assert_eq!(
1200            parse_repo_backend(" rust\n").unwrap(),
1201            crate::metrics::Backend::Rust
1202        );
1203    }
1204
1205    /// The default is the SIDECAR. Deploying this branch must not move anyone
1206    /// onto the new path by merely shipping; the switch has to be thrown.
1207    #[test]
1208    fn the_default_backend_is_the_sidecar() {
1209        assert_eq!(
1210            Config::default().repo_backend,
1211            crate::metrics::Backend::Sidecar
1212        );
1213    }
1214
1215    #[test]
1216    fn admin_seed_reuses_allowed_dids() {
1217        let open = Config::default();
1218        assert!(open.admin_seed_dids().is_empty());
1219        let gated = Config {
1220            allowed_dids: vec!["did:plc:me".to_string(), "did:plc:you".to_string()],
1221            ..Config::default()
1222        };
1223        assert_eq!(gated.admin_seed_dids(), &["did:plc:me", "did:plc:you"]);
1224    }
1225
1226    #[test]
1227    fn open_instance_allows_any_did() {
1228        let c = Config::default();
1229        assert!(c.did_allowed("did:plc:anything"));
1230    }
1231
1232    #[test]
1233    fn allow_list_gates_dids() {
1234        let c = Config {
1235            allowed_dids: vec!["did:plc:me".to_string()],
1236            ..Config::default()
1237        };
1238        assert!(c.did_allowed("did:plc:me"));
1239        assert!(!c.did_allowed("did:plc:stranger"));
1240    }
1241
1242    /// **The two halves of the retention policy read the same function.**
1243    ///
1244    /// The sweep deletes and the ingest floor refuses to store; written
1245    /// independently they drift, and a drift in this direction is the
1246    /// resurrection cycle — a row the store keeps, the sweep deletes, and the next
1247    /// poll re-inserts unread.
1248    #[test]
1249    fn retention_for_gives_a_publication_the_archive_ceiling_and_no_window() {
1250        let config = Config::default();
1251        assert_eq!(
1252            config.retention_for(crate::feed::FeedKind::Rss),
1253            (14, 180),
1254            "an RSS feed must keep the rolling window and the hard ceiling",
1255        );
1256        assert_eq!(
1257            config.retention_for(crate::feed::FeedKind::Publication),
1258            (0, 3_650),
1259            "a publication gets NO rolling window and the archive ceiling — a \
1260             14-day window stored zero rows from every real publication measured",
1261        );
1262        // The zero is load-bearing, not cosmetic: `prune_old_entries` honours a
1263        // ceiling only when the window is off (or strictly tighter), and
1264        // `ingest_floor` falls through to the ceiling on the same condition.
1265        let (days, hard) = config.retention_for(crate::feed::FeedKind::Publication);
1266        assert_eq!(days, 0);
1267        assert!(hard > 0);
1268    }
1269
1270    #[test]
1271    fn publication_retention_defaults_to_ten_years() {
1272        // Ten years is longer than the protocol, so it cannot truncate an archive
1273        // that exists today; the per-feed count cap is the space bound.
1274        assert_eq!(Config::default().publication_retention_days, 3_650);
1275    }
1276
1277    /// **`FEATHERREADER_STANDARD_SITE` is off unless it is set on.**
1278    ///
1279    /// The loader reads the process environment, which parallel tests cannot
1280    /// safely mutate, so the decision is a pure function of the raw value and
1281    /// tested as one. The case that matters is `None`: an unset flag must be
1282    /// `false`, or every deployment that never heard of standard.site would
1283    /// start accepting `at://` rows the poller skips.
1284    #[test]
1285    fn standard_site_is_off_unless_set_on() {
1286        let default = Config::default().standard_site;
1287        assert!(!default, "the shipped default must be off");
1288        // Unset means THE default, whatever it is — not a second copy of it.
1289        assert!(
1290            !parse_standard_site(None, false).unwrap(),
1291            "unset must mean off"
1292        );
1293        assert!(
1294            parse_standard_site(None, true).unwrap(),
1295            "unset must follow the default"
1296        );
1297        assert!(!parse_standard_site(Some("false"), true).unwrap());
1298        assert!(parse_standard_site(Some("true"), false).unwrap());
1299        assert!(parse_standard_site(Some("1"), false).unwrap());
1300        let err = parse_standard_site(Some("maybe"), false).unwrap_err();
1301        assert!(
1302            format!("{err:#}").contains("FEATHERREADER_STANDARD_SITE"),
1303            "the error must name the variable: {err:#}"
1304        );
1305    }
1306
1307    #[test]
1308    fn parse_bool_accepts_common_spellings() {
1309        assert!(parse_bool("Yes").unwrap());
1310        assert!(!parse_bool("OFF").unwrap());
1311        assert!(parse_bool("maybe").is_err());
1312    }
1313
1314    #[test]
1315    fn loopback_instance_keeps_dev_fallback_secrets() {
1316        // Default config is loopback + dev secrets: must be allowed to boot.
1317        let c = Config::default();
1318        assert!(!c.is_prod_like());
1319        assert!(c.validate_secrets().is_ok());
1320    }
1321
1322    #[test]
1323    fn public_bind_with_dev_cookie_secret_refuses_boot() {
1324        let c = Config {
1325            bind: SocketAddr::from(([0, 0, 0, 0], 8080)),
1326            ..Config::default()
1327        };
1328        assert!(c.is_prod_like());
1329        // Still carries the published dev cookie secret → must fail loud.
1330        let err = c.validate_secrets().unwrap_err().to_string();
1331        assert!(err.contains("FEATHERREADER_COOKIE_SECRET"), "{err}");
1332    }
1333
1334    #[test]
1335    fn public_bind_with_short_secret_refuses_boot() {
1336        let c = Config {
1337            bind: SocketAddr::from(([203, 0, 113, 5], 8080)),
1338            cookie_secret: "too-short".to_string(),
1339            ..Config::default()
1340        };
1341        assert!(c.is_prod_like());
1342        assert!(c.validate_secrets().is_err());
1343    }
1344
1345    #[test]
1346    fn public_bind_with_dev_sidecar_secret_refuses_boot() {
1347        let c = Config {
1348            bind: SocketAddr::from(([203, 0, 113, 5], 8080)),
1349            // Strong cookie secret, but sidecar secret still the dev default.
1350            cookie_secret: "x".repeat(48),
1351            ..Config::default()
1352        };
1353        let err = c.validate_secrets().unwrap_err().to_string();
1354        assert!(err.contains("SIDECAR_INTERNAL_SECRET"), "{err}");
1355    }
1356
1357    #[test]
1358    fn public_bind_with_strong_secrets_boots() {
1359        let c = Config {
1360            bind: SocketAddr::from(([203, 0, 113, 5], 8080)),
1361            cookie_secret: "a".repeat(48),
1362            sidecar: SidecarConfig {
1363                public_url: DEFAULT_SIDECAR_URL.to_string(),
1364                internal_url: DEFAULT_SIDECAR_URL.to_string(),
1365                internal_secret: "b".repeat(48),
1366            },
1367            ..Config::default()
1368        };
1369        assert!(c.is_prod_like());
1370        assert!(c.validate_secrets().is_ok());
1371    }
1372
1373    #[test]
1374    fn public_sidecar_url_without_internal_url_refuses_boot() {
1375        // Strong secrets, but the sidecar internal URL fell back to a
1376        // non-loopback public URL → the internal secret would go over the wire.
1377        let c = Config {
1378            bind: SocketAddr::from(([203, 0, 113, 5], 8080)),
1379            cookie_secret: "a".repeat(48),
1380            sidecar: SidecarConfig {
1381                public_url: "https://feather-reader.com/oauth".to_string(),
1382                internal_url: "https://feather-reader.com/oauth".to_string(),
1383                internal_secret: "b".repeat(48),
1384            },
1385            ..Config::default()
1386        };
1387        let err = c.validate_secrets().unwrap_err().to_string();
1388        assert!(err.contains("SIDECAR_INTERNAL_URL"), "{err}");
1389    }
1390
1391    #[test]
1392    fn public_sidecar_url_with_loopback_internal_url_boots() {
1393        // Same public sidecar URL, but an explicit loopback internal URL: safe.
1394        let c = Config {
1395            bind: SocketAddr::from(([203, 0, 113, 5], 8080)),
1396            cookie_secret: "a".repeat(48),
1397            sidecar: SidecarConfig {
1398                public_url: "https://feather-reader.com/oauth".to_string(),
1399                internal_url: "http://127.0.0.1:8081".to_string(),
1400                internal_secret: "b".repeat(48),
1401            },
1402            ..Config::default()
1403        };
1404        assert!(c.validate_secrets().is_ok());
1405    }
1406
1407    #[test]
1408    fn bot_secret_defaults_unset() {
1409        let c = Config::default();
1410        assert!(c.bot_secret.is_none());
1411        assert_eq!(c.claim_ttl_secs, DEFAULT_CLAIM_TTL_SECS);
1412    }
1413
1414    #[test]
1415    fn claim_ttl_must_be_positive() {
1416        // A positive TTL passes through unchanged.
1417        assert_eq!(validate_claim_ttl(3600).unwrap(), 3600);
1418        assert_eq!(
1419            validate_claim_ttl(DEFAULT_CLAIM_TTL_SECS).unwrap(),
1420            DEFAULT_CLAIM_TTL_SECS
1421        );
1422        // Zero and negative are rejected loudly (they mint dead, expired links).
1423        for bad in [0, -1, -1209600] {
1424            let err = validate_claim_ttl(bad).unwrap_err().to_string();
1425            assert!(err.contains("FEATHERREADER_CLAIM_TTL_SECS"), "{err}");
1426            assert!(err.contains("must be > 0"), "{err}");
1427        }
1428    }
1429
1430    #[test]
1431    fn loopback_instance_allows_weak_bot_secret() {
1432        // On a dev/loopback instance the bot secret isn't validated (the whole
1433        // secret policy is skipped), so even a short one is accepted.
1434        let c = Config {
1435            bot_secret: Some("short".to_string()),
1436            ..Config::default()
1437        };
1438        assert!(!c.is_prod_like());
1439        assert!(c.validate_secrets().is_ok());
1440    }
1441
1442    #[test]
1443    fn public_bind_with_short_bot_secret_refuses_boot() {
1444        let c = Config {
1445            bind: SocketAddr::from(([203, 0, 113, 5], 8080)),
1446            cookie_secret: "a".repeat(48),
1447            sidecar: SidecarConfig {
1448                public_url: DEFAULT_SIDECAR_URL.to_string(),
1449                internal_url: DEFAULT_SIDECAR_URL.to_string(),
1450                internal_secret: "b".repeat(48),
1451            },
1452            bot_secret: Some("too-short".to_string()),
1453            ..Config::default()
1454        };
1455        let err = c.validate_secrets().unwrap_err().to_string();
1456        assert!(err.contains("FEATHERREADER_BOT_SECRET"), "{err}");
1457    }
1458
1459    #[test]
1460    fn public_bind_with_strong_bot_secret_boots() {
1461        let c = Config {
1462            bind: SocketAddr::from(([203, 0, 113, 5], 8080)),
1463            cookie_secret: "a".repeat(48),
1464            sidecar: SidecarConfig {
1465                public_url: DEFAULT_SIDECAR_URL.to_string(),
1466                internal_url: DEFAULT_SIDECAR_URL.to_string(),
1467                internal_secret: "b".repeat(48),
1468            },
1469            bot_secret: Some("c".repeat(48)),
1470            ..Config::default()
1471        };
1472        assert!(c.validate_secrets().is_ok());
1473    }
1474
1475    #[test]
1476    fn public_bind_with_unset_bot_secret_boots() {
1477        // An unset bot secret is fine on prod (the endpoint is just disabled).
1478        let c = Config {
1479            bind: SocketAddr::from(([203, 0, 113, 5], 8080)),
1480            cookie_secret: "a".repeat(48),
1481            sidecar: SidecarConfig {
1482                public_url: DEFAULT_SIDECAR_URL.to_string(),
1483                internal_url: DEFAULT_SIDECAR_URL.to_string(),
1484                internal_secret: "b".repeat(48),
1485            },
1486            bot_secret: None,
1487            ..Config::default()
1488        };
1489        assert!(c.validate_secrets().is_ok());
1490    }
1491
1492    #[test]
1493    fn public_url_non_loopback_detection() {
1494        assert!(!public_url_is_non_loopback("http://localhost:8080"));
1495        assert!(!public_url_is_non_loopback("http://127.0.0.1:8080"));
1496        assert!(!public_url_is_non_loopback("http://[::1]:8080"));
1497        assert!(public_url_is_non_loopback("https://feather-reader.com"));
1498        assert!(public_url_is_non_loopback("http://203.0.113.5"));
1499    }
1500}