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}