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