Skip to main content

feather_reader/
web.rs

1//! The axum web layer — server-rendered HTML + a dash of htmx, **no SPA**.
2//!
3//! This module owns the HTTP surface: [`router`] builds an [`axum::Router`] over
4//! the shared [`AppState`], wiring the store, feed, atproto, and config seams into
5//! a small set of typography-first, dark-mode-ready views rendered with
6//! [`askama`] templates (under `templates/`). Progressive enhancement is a single
7//! vendored `htmx` script plus a tiny keyboard handler (`static/keyboard.js`);
8//! every interaction also works as a plain HTML form POST, so the reader is fully
9//! usable with JavaScript disabled.
10//!
11//! ## HTTP surface
12//!
13//! * `GET  /health` — liveness + version, as `text/plain`.
14//! * `GET  /` — the reader: a folders/feeds sidebar (from the PDS records layer)
15//!   plus the main article list. Query params pick the scope (`?feed=…` /
16//!   `?folder=…` / all) and the view (`?view=unread|all|starred`).
17//! * `GET  /entries/{id}` — the clean, distraction-free reader for one entry,
18//!   with prev/next within the current list.
19//! * `POST /entries/{id}/read` — mark an entry read/unread (htmx row swap).
20//! * `POST /entries/{id}/star` — star/unstar; writes a
21//!   `community.lexicon.rss.saved` record to the user's PDS.
22//! * `POST /read-all` — mark-all-read (per feed via `?feed=…`, else everything).
23//! * `POST /subscriptions` — subscribe by URL (autodiscover → PDS record).
24//! * `POST /subscriptions/{rkey}/delete` — unsubscribe (delete the PDS record).
25//! * `POST /subscriptions/{rkey}/rename` — retitle / move a feed to a folder.
26//! * `POST /folders` — create a folder record.
27//! * `POST /folders/{rkey}/rename` — rename a folder record.
28//! * `POST /folders/{rkey}/delete` — delete a folder record.
29//! * `POST /opml` — OPML import (multipart upload *or* pasted textarea) → bulk
30//!   subscription records in the PDS.
31//! * `GET  /opml/export` — OPML export (records → a downloadable document).
32//! * `GET /login` + `POST /login` + `/oauth/callback` + `/logout` — the atproto
33//!   OAuth sign-in flow (routed through the sidecar).
34//! * `GET /claim?t=<token>` — the follow→invite bot's claim link: an opaque token
35//!   reserving a pre-minted invite code; behaves like a successful `/beta/redeem`
36//!   (sets the reserving cookie → `/login`).
37//! * `POST /bot/claims` — headless, shared-secret (`X-Bot-Secret`) mint of a claim
38//!   code + token/url for the bot to post. Cap-aware (409 when full).
39//!
40//! ## Identity — a cookie-resolved atproto session
41//!
42//! Per-request identity comes from a **signed session cookie** (`fr_session`)
43//! keyed by the logged-in DID, set by [`oauth_callback`] and read by
44//! [`current_session`] / [`current_did`]. For local runs without the sidecar,
45//! [`Config::dev_did`] (env `FEATHERREADER_DEV_DID`) supplies a fallback identity.
46//! All PDS writes route through the [`crate::atproto::SidecarClient`]; a live-PDS
47//! write needs a real OAuth session, but the full write path is built and unit-
48//! tested to the sidecar boundary.
49
50use std::collections::HashMap;
51use std::net::IpAddr;
52use std::sync::Mutex;
53use std::time::{Duration, Instant};
54
55use askama::Template;
56use axum::{
57    extract::{ConnectInfo, DefaultBodyLimit, Multipart, Path, Query, State},
58    http::{header, HeaderMap, StatusCode},
59    middleware::{self, Next},
60    response::{Html, IntoResponse, Redirect, Response},
61    routing::{get, post},
62    Form, Router,
63};
64use serde::Deserialize;
65use std::net::SocketAddr;
66use tower_http::services::{ServeDir, ServeFile};
67use tower_http::set_header::SetResponseHeaderLayer;
68use tower_http::trace::TraceLayer;
69use tracing::{info, warn};
70
71use crate::config::Config;
72use crate::lexicon::{self, Folder, Saved, Subscription};
73use crate::safe_link::SafeLink;
74use crate::{feed, store, AppState, Session, VERSION};
75
76// The OPML import/export module lives at `src/opml.rs` but isn't declared in the
77// crate root (`lib.rs`), which is outside this phase's edit surface. Wire it in
78// here via an explicit path so the reader's OPML routes can use the canonical
79// `parse_opml` / `to_opml` without duplicating that logic.
80#[path = "opml.rs"]
81mod opml;
82
83/// The name of the signed session cookie.
84const SESSION_COOKIE: &str = "fr_session";
85
86/// The name of the short-lived signed **invite** cookie.
87///
88/// Set by `POST /beta/redeem` on a valid, capacity-ok code and consumed by the
89/// OAuth callback. It reserves *intent* to redeem a specific code before the
90/// visitor ever starts the OAuth handshake, so a non-invited visitor can't burn
91/// a sidecar handshake (pre-handshake gate). It carries the invite code, HMAC-
92/// signed with the same key as the session cookie.
93const INVITE_COOKIE: &str = "fr_invite";
94
95/// Browser-binding cookie for an in-flight OAuth login (Rust backend only).
96///
97/// `state` alone cannot stop a login CSRF: it lives in a server-global table, so
98/// a stolen `state` replayed from ANOTHER browser matches just as well as from
99/// the one that started the flow. This cookie is what makes the callback
100/// browser-specific — the pending row stores only its hash, and a callback that
101/// cannot present it is refused.
102const OAUTH_BINDING_COOKIE: &str = "fr_oauth";
103
104/// How long an in-flight login may sit, matching the pending row's own TTL.
105const OAUTH_BINDING_MAX_AGE_SECS: i64 = 600;
106
107/// TTL (seconds) for a minted invite code and for the reserving invite cookie.
108/// Short enough that a reserved-but-unclaimed seat frees quickly.
109const INVITE_TTL_SECS: i64 = 1800;
110
111/// The canonical AGPL-3.0 source repository — surfaced in the footer, the
112/// sign-in pitch, and `/about`.
113const REPO_URL: &str = "https://github.com/justin-stanley/feather-reader";
114
115/// The tip / support link (cloud plan public-experiment UI).
116const KOFI_URL: &str = "https://ko-fi.com/justinstanley";
117
118/// The published crate on crates.io — surfaced on the signed-out landing page.
119const CRATES_URL: &str = "https://crates.io/crates/feather-reader";
120
121/// The Content-Security-Policy applied to every response.
122///
123/// Tuned to keep the app fully working while neutralising injected script:
124/// * `default-src 'self'` — same-origin baseline.
125/// * `script-src 'self'` — only our vendored `htmx.min.js` + `keyboard.js` from
126///   `/static`; **no** `'unsafe-inline'`, so an injected `<script>` or a
127///   `javascript:` href (F4) cannot execute. (The design's templates carry no
128///   inline event handlers — every control is wired in `keyboard.js`.)
129/// * `style-src 'self' 'unsafe-inline'` — the linked stylesheet plus the small
130///   inline styles htmx toggles for its request indicators.
131/// * `img-src 'self' https: data:` — feed content routinely embeds remote
132///   images; allow https + data URIs but not other schemes.
133/// * `form-action 'self'`, `base-uri 'self'`, `frame-ancestors 'none'` — lock
134///   down form posts, `<base>` hijacking, and clickjacking.
135/// * `object-src 'none'` — no plugins.
136const CONTENT_SECURITY_POLICY: &str = "default-src 'self'; \
137     script-src 'self'; \
138     style-src 'self' 'unsafe-inline'; \
139     img-src 'self' https: data:; \
140     font-src 'self'; \
141     connect-src 'self'; \
142     form-action 'self'; \
143     base-uri 'self'; \
144     frame-ancestors 'none'; \
145     object-src 'none'";
146
147/// The resolved identity for the current request.
148///
149/// `did` is the primary key for all per-user local state; `handle` is display
150/// only; `sid` is the opaque server-side session id the cookie carried (needed
151/// so logout can revoke exactly this session). Sourced from the signed cookie
152/// (real login) or, if none, the configured dev DID fallback.
153#[derive(Clone, Debug)]
154struct CurrentUser {
155    did: String,
156    handle: Option<String>,
157    /// The opaque session id, if this identity came from a real cookie session
158    /// (absent for the dev-DID fallback, which has no server-side session row).
159    sid: Option<String>,
160}
161
162/// Resolve the current request's session from the signed cookie, falling back to
163/// the configured dev DID (env `FEATHERREADER_DEV_DID`) for local runs.
164///
165/// The cookie carries an opaque server-minted session id (not the DID). We
166/// verify its HMAC, look the id up in the registry, and — crucially —
167/// **re-check the DID against the closed-beta gate on every request**
168/// ([`store::has_beta_access`]), not just at the OAuth callback, so revoking a
169/// DID's beta seat takes effect immediately for already-issued cookies. (The
170/// gate replaced the old static `ALLOWED_DIDS` check; `ALLOWED_DIDS` remains the
171/// admin-bootstrap seed, granted a seat at startup via `ensure_seed`.)
172async fn current_session(state: &AppState, headers: &HeaderMap) -> Option<CurrentUser> {
173    if let Some(sid) = cookie::verify_session(headers, &state.config.cookie_secret) {
174        if let Some(session) = state.sessions.get(&sid) {
175            if store::has_beta_access(&state.db, &session.did)
176                .await
177                .unwrap_or(false)
178            {
179                return Some(CurrentUser {
180                    did: session.did,
181                    handle: session.handle,
182                    sid: Some(sid),
183                });
184            }
185            // DID no longer holds a beta seat: treat as logged out (and drop the
186            // stale server-side session so the dead cookie can't linger).
187            state.sessions.remove(&sid);
188        }
189    }
190    // No valid cookie: dev fallback only if explicitly configured *and* still
191    // inside the beta gate (seeded via ensure_seed / a redeemed code).
192    if let Some(did) = state.config.dev_did.clone() {
193        if store::has_beta_access(&state.db, &did)
194            .await
195            .unwrap_or(false)
196        {
197            return Some(CurrentUser {
198                did,
199                handle: None,
200                sid: None,
201            });
202        }
203    }
204    None
205}
206
207/// The current request's DID, or `None` when logged out (no cookie, no dev DID).
208async fn current_did(state: &AppState, headers: &HeaderMap) -> Option<String> {
209    current_session(state, headers).await.map(|u| u.did)
210}
211
212/// Build the application router over shared [`AppState`].
213///
214/// Wires the reader routes, the health check, and the `/static` asset mount
215/// (the stylesheet, vendored htmx, and the keyboard handler, served from
216/// `static/` via [`ServeDir`]). A [`TraceLayer`] gives per-request tracing.
217pub fn router(state: AppState) -> Router {
218    // The shared per-IP rate limiter for the abuse-prone paths (login, redeem,
219    // and the write endpoints). One instance is cloned into the state closure of
220    // the `rate_limit` middleware.
221    let limiter = RateLimiter::shared();
222    // The trusted client-IP source for the limiter (a proxy header the operator
223    // controls, or the socket peer when unset). Bundled with the limiter so the
224    // middleware derives a spoof-resistant IP.
225    let rl_state = RateLimitState {
226        limiter,
227        trusted_header: state.config.trusted_ip_header.clone(),
228    };
229
230    Router::new()
231        .route("/health", get(health))
232        .route("/about", get(about))
233        .route("/stats", get(stats))
234        .route("/privacy", get(privacy))
235        .route("/terms", get(terms))
236        .route("/manage", get(manage))
237        .route("/", get(index))
238        .route("/entries/{id}", get(entry_view))
239        .route("/entries/{id}/read", post(mark_read))
240        .route("/entries/{id}/star", post(toggle_star))
241        .route("/saved/{rkey}/delete", post(unsave_record))
242        .route("/read-all", post(mark_all_read))
243        .route("/subscriptions", post(add_subscription))
244        .route("/subscriptions/{rkey}/delete", post(delete_subscription))
245        .route("/subscriptions/{rkey}/rename", post(rename_subscription))
246        .route("/folders", post(create_folder))
247        .route("/folders/{rkey}/rename", post(rename_folder))
248        .route("/folders/{rkey}/delete", post(delete_folder))
249        // OPML import takes untrusted uploads: cap the body so a huge upload
250        // can't OOM (residual body-cap), on top of the streamed feed-fetch cap.
251        .route(
252            "/opml",
253            post(import_opml).layer(DefaultBodyLimit::max(OPML_BODY_LIMIT)),
254        )
255        .route("/opml/export", get(export_opml))
256        .route("/login", get(login_form).post(login_submit))
257        .route(
258            "/beta/redeem",
259            get(beta_redeem_form).post(beta_redeem_submit),
260        )
261        // The follow→invite bot's claim link: a public skeet points a new
262        // follower here with an opaque token that reserves a pre-minted code.
263        .route("/claim", get(claim))
264        // Headless bot mint endpoint (shared-secret, not OAuth). Mints a claim
265        // code + returns its token/url for the bot to post.
266        .route("/bot/claims", post(bot_mint_claim))
267        .route("/admin/invites", post(admin_mint_invites))
268        .route("/admin/metrics", get(admin_metrics))
269        .route("/oauth/client-metadata.json", get(oauth_client_metadata))
270        .route("/oauth/jwks.json", get(oauth_jwks))
271        .route("/account/delete", post(account_delete))
272        .route("/oauth/callback", get(oauth_callback))
273        .route("/logout", post(logout))
274        .nest_service("/static", ServeDir::new("static"))
275        // Browsers (and some feed clients) request /favicon.ico at the root
276        // regardless of the <link rel="icon"> tags; serve the same icon that
277        // lives under /static so the bare path stops 404-ing.
278        .route_service("/favicon.ico", ServeFile::new("static/favicon.ico"))
279        // Cache-Control (viral/CDN plan): `public, max-age=300` on the cacheable
280        // logged-out landing + static assets, `no-store` on anything that
281        // rendered a session's private view. Runs *inside* the security layers so
282        // the CSP/nosniff/frame headers are untouched.
283        .layer(middleware::from_fn(cache_control))
284        // Per-IP rate limit on the abuse-prone paths (429 over the limit). Runs
285        // as a middleware so it sees the matched path + the peer IP.
286        .layer(middleware::from_fn_with_state(rl_state, rate_limit))
287        .layer(TraceLayer::new_for_http())
288        // Baseline security headers on *every* response (F4). The CSP is the
289        // backstop that neutralises any XSS that slips past sanitization; the
290        // others harden sniffing, framing, and referrer leakage.
291        .layer(static_header_layer(
292            "content-security-policy",
293            CONTENT_SECURITY_POLICY,
294        ))
295        .layer(static_header_layer("x-content-type-options", "nosniff"))
296        .layer(static_header_layer(
297            "referrer-policy",
298            "strict-origin-when-cross-origin",
299        ))
300        .layer(static_header_layer("x-frame-options", "DENY"))
301        .with_state(state)
302}
303
304/// Body-size ceiling for the OPML import upload: **1 MiB, deliberately below
305/// axum's 2 MiB default.**
306///
307/// The value used to BE the framework default, which made the route's own
308/// `DefaultBodyLimit` layer a no-op: removing the layer changed nothing, so
309/// nothing could test it, and the ceiling this route wanted was whatever the
310/// framework happened to pick. Sized to this route instead — one outline is
311/// ~92 bytes, so 1 MiB carries ~11 000 of them against a per-DID cap
312/// (`max_subs_per_did`, default 500) the import trims to anyway. Anything
313/// larger is not a subscription list.
314///
315/// Being strictly tighter than the default is what makes the layer both real
316/// and pinnable: `opml_import_over_the_route_cap_is_refused_below_the_framework_default`
317/// uploads a payload that only this limit refuses.
318const OPML_BODY_LIMIT: usize = 1024 * 1024;
319
320/// axum's own `DefaultBodyLimit` (2 MiB as of axum 0.8), for the test that
321/// uploads a payload between the two ceilings.
322///
323/// **What matters is only that it EXCEEDS [`OPML_BODY_LIMIT`]**, not that this
324/// number is exact — axum does not export it, so it cannot be imported. The
325/// exceeding is what the test's mutation demonstrates: with the route's layer
326/// removed, a payload of this size is accepted. If axum ever lowers its
327/// default below ours, that mutation stops failing and the compile-time
328/// assertion below is the thing to revisit.
329#[cfg(test)]
330const AXUM_DEFAULT_BODY_LIMIT: usize = 2 * 1024 * 1024;
331
332/// The route's cap must stay strictly tighter than the framework's, or its
333/// layer is a no-op again. A compile error, not a test failure: this is a
334/// property of the two constants, and nothing should be able to build a binary
335/// where it is false.
336#[cfg(test)]
337const _: () = assert!(
338    OPML_BODY_LIMIT < AXUM_DEFAULT_BODY_LIMIT,
339    "OPML_BODY_LIMIT must be tighter than axum's default, or the route's layer does nothing"
340);
341
342/// A response-header layer that sets `name: value` on every response, overriding
343/// any existing header of that name. `name`/`value` must be valid static header
344/// tokens (they are, for our fixed security headers).
345fn static_header_layer(
346    name: &'static str,
347    value: &'static str,
348) -> SetResponseHeaderLayer<header::HeaderValue> {
349    SetResponseHeaderLayer::overriding(
350        header::HeaderName::from_static(name),
351        header::HeaderValue::from_static(value),
352    )
353}
354
355// ---------------------------------------------------------------------------
356// Per-IP rate limiting (token bucket, self-contained — no extra crate)
357// ---------------------------------------------------------------------------
358
359/// The abuse-prone paths the rate limiter guards (429 over the limit): the OAuth
360/// kick-off and callback, the invite redeem, logout, the mutating write
361/// endpoints, and mark-read/star/mark-all. Plain read-only navigation is
362/// intentionally *not* limited.
363///
364/// The criterion is **does this path make an outbound request**, not "does it
365/// mutate" — the two diverge, and every miss so far has been on the outbound
366/// side. This is an allowlist a new route has to be added to by hand, which is
367/// exactly why it has now been missed three times: `/saved/` (fixed), then
368/// `/oauth/callback` and `/logout`. The callback was the bad one — it is the
369/// only path here reachable with no session at all.
370///
371/// Known and deliberate gaps: `GET /`, `GET /manage` and `GET /opml/export` each
372/// make PDS calls but are ordinary authenticated navigation, and throttling them
373/// would degrade normal reading. They are bounded by needing a valid session.
374fn is_rate_limited_path(path: &str, method: &axum::http::Method) -> bool {
375    use axum::http::Method;
376    // `/claim` is a GET (a link the bot posts), but it consumes a reservation and
377    // a claim token in a public URL is grabbable, so it MUST be per-IP limited
378    // like the other abuse-prone entry points — not just `/login`.
379    // `/oauth/callback` is a GET, is UNAUTHENTICATED, and every hit performs a
380    // real outbound round-trip — a sidecar `resolve_session` or a full token
381    // exchange against a PDS. Anyone could spend one outbound request per hit.
382    // It is the only entry point here that needs no session at all.
383    if method != Method::POST
384        && !(method == Method::GET
385            && (path == "/login" || path == "/claim" || path == "/oauth/callback"))
386    {
387        return false;
388    }
389    match path {
390        // `/logout` and `/oauth/callback` are here because they make outbound
391        // calls, not because they mutate: logout revokes at the PDS (up to two
392        // round-trips) and the callback exchanges a code. The list is by
393        // *network cost*, which is what the limiter is actually for.
394        "/login" | "/claim" | "/oauth/callback" | "/logout" | "/beta/redeem" | "/subscriptions"
395        | "/opml" | "/read-all" | "/admin/invites" | "/bot/claims" | "/account/delete"
396        | "/folders" => true,
397        // Every per-record subscription/folder mutation (delete/rename) and the
398        // star/mark-read taps make a sidecar/PDS round-trip, so limit them too.
399        p => {
400            (p.starts_with("/entries/") && (p.ends_with("/read") || p.ends_with("/star")))
401                // Unsaving makes a DPoP-signed deleteRecord round-trip to the
402                // PDS, which is exactly the reason the neighbours above are
403                // limited. It was added as a new route and not added here.
404                || p.starts_with("/saved/")
405                || p.starts_with("/subscriptions/")
406                || p.starts_with("/folders/")
407        }
408    }
409}
410
411/// Middleware state for [`rate_limit`]: the shared limiter plus the trusted
412/// client-IP header (if any). Cloned into every request; both fields are cheap.
413#[derive(Clone)]
414struct RateLimitState {
415    limiter: RateLimiter,
416    /// The lowercased proxy header the operator trusts for the client IP, or
417    /// `None` to trust only the socket peer. See [`client_ip`].
418    trusted_header: Option<String>,
419}
420
421/// A tiny per-IP token-bucket rate limiter. Each IP gets [`RATE_BURST`] tokens
422/// that refill at [`RATE_REFILL_PER_SEC`]/sec; a request costs one token and is
423/// rejected (429) when the bucket is empty. Self-contained (no `tower_governor`
424/// dependency → no network fetch at build, deterministic offline CI).
425#[derive(Clone)]
426struct RateLimiter {
427    inner: std::sync::Arc<Mutex<RateLimiterState>>,
428}
429
430/// The limiter's shared state: the buckets plus when they were last swept.
431struct RateLimiterState {
432    buckets: HashMap<IpAddr, Bucket>,
433    last_sweep: Instant,
434}
435
436/// One IP's token bucket: a fractional token count + the last-refill instant.
437struct Bucket {
438    tokens: f64,
439    last: Instant,
440}
441
442/// Burst capacity per IP — how many requests can arrive back-to-back.
443const RATE_BURST: f64 = 20.0;
444/// Steady-state refill rate (tokens/sec) once the burst is spent.
445const RATE_REFILL_PER_SEC: f64 = 1.0;
446/// Evict idle buckets older than this so the map can't grow unbounded.
447const RATE_IDLE_EVICT: Duration = Duration::from_secs(3600);
448
449/// How often the idle sweep may actually run.
450///
451/// The sweep used to run on EVERY guarded request — an O(n) scan of the whole
452/// map to find entries that, by construction, can only age out on an hour
453/// boundary. `GET /login` and `GET /claim` are guarded and unauthenticated, so
454/// under any volume of distinct source IPs the server spent its single shared
455/// core re-walking a map whose contents had not changed. Once a minute is
456/// plenty: it bounds bucket lifetime at `RATE_IDLE_EVICT + RATE_SWEEP_EVERY`.
457const RATE_SWEEP_EVERY: Duration = Duration::from_secs(60);
458
459/// Most buckets kept. At roughly 100 bytes each this is ~1 MB — a bound, not a
460/// target, sized so ordinary traffic never reaches it.
461///
462/// The idle eviction above was the only bound, and it is a TIME bound, which
463/// says nothing about how many distinct IPs can arrive inside one hour.
464/// `net.rs` bounds the equivalent structure by count (`MAX_PINNED_CLIENTS`);
465/// this one did not.
466const MAX_RATE_BUCKETS: usize = 10_000;
467
468/// When the cap is hit, evict down to this fraction of it rather than removing
469/// a single entry — so the O(n) eviction happens once per `cap/8` requests
470/// instead of once per request while the map sits full.
471const RATE_EVICT_DOWN_TO: usize = MAX_RATE_BUCKETS * 7 / 8;
472
473impl RateLimiter {
474    /// A fresh, shared limiter (cloned into the middleware state).
475    fn shared() -> Self {
476        Self {
477            inner: std::sync::Arc::new(Mutex::new(RateLimiterState {
478                buckets: HashMap::new(),
479                last_sweep: Instant::now(),
480            })),
481        }
482    }
483
484    /// Charge one token for `ip`; returns `true` if allowed, `false` if the
485    /// bucket is empty (→ 429).
486    fn check(&self, ip: IpAddr) -> bool {
487        self.check_at(ip, Instant::now())
488    }
489
490    /// [`check`](Self::check) with the clock injected, so the sweep and eviction
491    /// paths below are reachable in a test without sleeping through an hour.
492    fn check_at(&self, ip: IpAddr, now: Instant) -> bool {
493        let mut state = match self.inner.lock() {
494            Ok(m) => m,
495            // A poisoned lock shouldn't take the site down — fail open.
496            Err(p) => p.into_inner(),
497        };
498
499        // Idle sweep, at most once per `RATE_SWEEP_EVERY`.
500        if now.duration_since(state.last_sweep) >= RATE_SWEEP_EVERY {
501            state
502                .buckets
503                .retain(|_, b| now.duration_since(b.last) < RATE_IDLE_EVICT);
504            state.last_sweep = now;
505        }
506
507        // Hard size bound, independent of the time bound above.
508        //
509        // Evicting LEAST-RECENTLY-USED is what makes this safe to do at all. An
510        // attacker cannot use eviction to clear their OWN throttled bucket: that
511        // bucket is by definition the most recently touched, so it is the last
512        // thing this removes. Going quiet long enough to become the oldest entry
513        // is exactly what the refill already grants for free.
514        if state.buckets.len() >= MAX_RATE_BUCKETS && !state.buckets.contains_key(&ip) {
515            let mut by_age: Vec<(IpAddr, Instant)> =
516                state.buckets.iter().map(|(k, b)| (*k, b.last)).collect();
517            by_age.sort_unstable_by_key(|(_, last)| *last);
518            for (victim, _) in by_age
519                .into_iter()
520                .take(state.buckets.len().saturating_sub(RATE_EVICT_DOWN_TO))
521            {
522                state.buckets.remove(&victim);
523            }
524            warn!(
525                buckets = state.buckets.len(),
526                "rate-limit bucket cap reached; evicted the least recently seen clients"
527            );
528        }
529
530        let bucket = state.buckets.entry(ip).or_insert(Bucket {
531            tokens: RATE_BURST,
532            last: now,
533        });
534        let elapsed = now.duration_since(bucket.last).as_secs_f64();
535        bucket.tokens = (bucket.tokens + elapsed * RATE_REFILL_PER_SEC).min(RATE_BURST);
536        bucket.last = now;
537        if bucket.tokens >= 1.0 {
538            bucket.tokens -= 1.0;
539            true
540        } else {
541            false
542        }
543    }
544}
545
546/// The **trusted** client IP for a request.
547///
548/// Security: a naive limiter that trusts the *left-most* `X-Forwarded-For` hop
549/// is fully bypassable — the left-most value is attacker-supplied (any client
550/// can send `X-Forwarded-For: <random>`), so each forged value lands in a fresh
551/// bucket and the per-IP limit never bites. We therefore derive the IP only from
552/// a source the operator controls:
553///
554/// * If `trusted_header` is configured (e.g. `Fly-Client-IP`,
555///   `CF-Connecting-IP`), we read the client IP from THAT header only — it is
556///   set by the proxy we run in front and overwrites any client-supplied copy.
557///   We take the LAST value if the header happens to be a comma list (the hop
558///   the trusted proxy appended), which is also the correct read for a
559///   right-most-`X-Forwarded-For` deployment where the operator points
560///   `trusted_header` at `x-forwarded-for`.
561/// * Otherwise we ignore all forwarding headers and use the socket peer
562///   (`ConnectInfo`) — correct for a direct bind with no proxy.
563///
564/// Returns `None` only when neither source yields a parseable IP (the limiter
565/// then fails open for that one request).
566fn client_ip(
567    headers: &HeaderMap,
568    conn: Option<&SocketAddr>,
569    trusted_header: Option<&str>,
570) -> Option<IpAddr> {
571    if let Some(name) = trusted_header {
572        if let Some(raw) = headers.get(name).and_then(|v| v.to_str().ok()) {
573            // Right-most hop is the one the trusted proxy appended; earlier
574            // entries may be client-forged, so never trust the left-most.
575            if let Some(last) = raw.split(',').next_back() {
576                if let Ok(ip) = last.trim().parse::<IpAddr>() {
577                    return Some(ip);
578                }
579            }
580        }
581        // Trusted header absent/unparseable → fall through to the socket peer.
582    }
583    conn.map(|s| s.ip())
584}
585
586/// Rate-limit middleware: 429 on the abuse-prone paths once an IP's bucket is
587/// empty; every other request (and every non-guarded path) passes through. The
588/// peer `SocketAddr` is read from the request extension `ConnectInfo` sets (via
589/// `into_make_service_with_connect_info`), preferring `X-Forwarded-For`.
590async fn rate_limit(
591    State(rl): State<RateLimitState>,
592    req: axum::extract::Request,
593    next: Next,
594) -> Response {
595    let path = req.uri().path().to_string();
596    let method = req.method().clone();
597    if is_rate_limited_path(&path, &method) {
598        let conn = req
599            .extensions()
600            .get::<ConnectInfo<SocketAddr>>()
601            .map(|c| c.0);
602        let ip = client_ip(req.headers(), conn.as_ref(), rl.trusted_header.as_deref());
603        // Deliberately fail OPEN when no client IP is derivable (no trusted
604        // header / no socket peer): there is no per-IP key to enforce, and a
605        // blanket 429 would self-DoS every guarded path (incl. /login). This is
606        // safe precisely because we never key on an attacker-forged XFF — see
607        // `rate_limit_ignores_spoofed_xff_rotation`.
608        if let Some(ip) = ip {
609            if !rl.limiter.check(ip) {
610                warn!(%ip, %path, "rate limit exceeded");
611                return (
612                    StatusCode::TOO_MANY_REQUESTS,
613                    [(header::RETRY_AFTER, "1")],
614                    "rate limit exceeded\n",
615                )
616                    .into_response();
617            }
618        }
619    }
620    next.run(req).await
621}
622
623// ---------------------------------------------------------------------------
624// Cache-Control (viral / CDN vs. private authenticated views)
625// ---------------------------------------------------------------------------
626
627/// Cache-Control middleware. Emits `public, max-age=300` on the cacheable
628/// logged-out surfaces (the `/login` landing without a handle, `/about`,
629/// `/privacy`, `/terms`, and the `/static/*` assets) and `no-store` on the
630/// authenticated app pages, so a CDN /
631/// browser can hold the viral landing while never caching a signed-in user's
632/// private view. Never overrides a handler that already set Cache-Control.
633async fn cache_control(req: axum::extract::Request, next: Next) -> Response {
634    let path = req.uri().path().to_string();
635    // The logged-out landing is only cacheable when it's the bare form — a
636    // `?handle=` GET kicks off OAuth (a redirect), which must not be cached.
637    let is_login_landing = path == "/login"
638        && req.method() == axum::http::Method::GET
639        && !req.uri().query().unwrap_or("").contains("handle=");
640    let public = is_login_landing
641        || path == "/about"
642        || path == "/privacy"
643        || path == "/terms"
644        || path.starts_with("/static/");
645
646    let mut resp = next.run(req).await;
647    if resp.headers().contains_key(header::CACHE_CONTROL) {
648        return resp;
649    }
650    let value = if public {
651        "public, max-age=300"
652    } else {
653        "no-store"
654    };
655    if let Ok(hv) = header::HeaderValue::from_str(value) {
656        resp.headers_mut().insert(header::CACHE_CONTROL, hv);
657    }
658    resp
659}
660
661// ---------------------------------------------------------------------------
662// Health
663// ---------------------------------------------------------------------------
664
665/// Run `/health`'s database probe. **The single path, so a test cannot assert
666/// on a string the handler is free to ignore** — a named constant alone was not
667/// enough: the test read the constant while the handler passed `query_scalar`
668/// whatever it liked, so degrading the real call to `SELECT 1` shipped green.
669async fn health_db_probe(pool: &store::Pool) -> Result<Option<i64>, sqlx::Error> {
670    sqlx::query_scalar::<_, i64>(HEALTH_DB_PROBE_SQL)
671        .fetch_optional(pool)
672        .await
673}
674
675/// The statement `/health` uses to prove the database is readable.
676///
677/// **A named constant so the test can assert on the query that actually runs.**
678/// `the_health_probe_opens_a_real_table` used to `EXPLAIN` a hand-typed copy of
679/// this string, so degrading the real probe to `SELECT 1` — which opens no page
680/// and therefore cannot detect a broken database — left the suite green.
681const HEALTH_DB_PROBE_SQL: &str = "SELECT 1 FROM feeds LIMIT 1";
682
683/// How long `/health` will wait for its database ping before calling it broken.
684///
685/// Under `fly.toml`'s 3 s check timeout, so a hung pool produces a 503 this
686/// handler chose rather than a timeout Fly inferred — the difference between a
687/// log line that says why and one that says nothing.
688const HEALTH_DB_TIMEOUT: Duration = Duration::from_secs(2);
689
690/// Floor for the poll-heartbeat staleness threshold. **Reported, never fatal** —
691/// see the handler for why.
692///
693/// The threshold itself is derived from the configured tick
694/// ([`health_tick_stale_secs`]): hardcoding 15 minutes meant an operator who
695/// raised `FEATHERREADER_POLL_TICK_SECS` above 900 got a permanent `poller:
696/// stale` in the body the deployment docs now tell them to alert on.
697const HEALTH_TICK_STALE_FLOOR_SECS: i64 = 15 * 60;
698
699/// How long without a completed tick before the poller reads as stale: several
700/// tick intervals, floored, so a normally-paced loop never trips it and a
701/// genuinely wedged one always does.
702fn health_tick_stale_secs(tick: Duration) -> i64 {
703    let tick = i64::try_from(tick.as_secs()).unwrap_or(i64::MAX);
704    tick.saturating_mul(5).max(HEALTH_TICK_STALE_FLOOR_SECS)
705}
706
707/// The poll tick this instance is configured for. Read from the same env var
708/// `scheduler.rs` reads, because the scheduler lives in the binary crate and the
709/// handler cannot see its constants.
710fn configured_poll_tick() -> Duration {
711    std::env::var("FEATHERREADER_POLL_TICK_SECS")
712        .ok()
713        .and_then(|v| v.trim().parse::<u64>().ok())
714        .filter(|s| *s > 0)
715        .map_or(DEFAULT_POLL_TICK_SECS, Duration::from_secs)
716}
717
718/// Mirrors `scheduler::DEFAULT_POLL_TICK`, which lives in the BINARY crate and
719/// so cannot be imported here. Duplicated deliberately and named, rather than
720/// left as a bare `60` inside the parse chain, so the drift is at least visible
721/// if the scheduler's value ever moves.
722const DEFAULT_POLL_TICK_SECS: Duration = Duration::from_secs(60);
723
724/// Grace period after boot before a poller that has never ticked is called
725/// `stale` rather than `not-yet-ticked`.
726///
727/// Without this the two are indistinguishable forever, which matters precisely
728/// in the case the startup delays were added for: in a crash loop with 30 s+ boot
729/// cycles the poller never reaches its first tick, so `/health` reported the
730/// benign `not-yet-ticked` on every single probe and the heartbeat could not
731/// detect the failure mode it exists for. `run_poller` returning early — a failed
732/// HTTP client build — has the same shape and was equally invisible.
733///
734/// Sized off the poller's own startup delay plus its tick, with slack.
735const HEALTH_FIRST_TICK_GRACE_SECS: i64 = 5 * 60;
736
737/// `GET /health` — does this process still work, and what are its loops doing?
738///
739/// This used to return a constant string, touching no database, no pool and no
740/// scheduler state — while being the ONLY automated signal in `fly.toml`, whose
741/// sole other failure detector is a child process exiting. It proved the HTTP
742/// listener was up and nothing else.
743///
744/// **What can fail the check: the database, and only the database.** A process
745/// that cannot reach its store serves nothing, so a restart is the right
746/// response and this returns 503. The probe is a read (`SELECT 1`), which in WAL
747/// mode is not blocked by any writer — so the retention sweep, the poller and a
748/// login burst cannot make this flap. That property is the reason it is a read
749/// and not, say, a write canary.
750///
751/// **What is reported but never fails the check: everything else.** A stale poll
752/// heartbeat, a watermark pause, a missing OAuth runtime — all real problems,
753/// and none of them a reason to stop serving.
754///
755/// That last clause is the whole justification, and it is NOT the one this
756/// comment used to give. It said "Fly restarts on a failed check", which is
757/// false — verified against Fly's own docs, which state it three times: *"your
758/// Machines won't automatically restart or stop due to failing their health
759/// checks"*. A failing `[[http_service.checks]]` check makes Fly Proxy stop
760/// ROUTING to the Machine. Nothing restarts it. That capability existed on Apps
761/// V1 (`restart_limit`) and has no successor on Machines.
762///
763/// The corrected model makes the conclusion stronger, not weaker. With one
764/// Machine there is no healthy peer to shift traffic to, so a 503 here is not a
765/// failover — it is a total outage that lasts exactly as long as the condition,
766/// and it also fails a `fly deploy` (rolling strategy, no auto-rollback). So the
767/// question the status code answers is not "would a restart fix this" but **"can
768/// this process still serve a useful request at all"**. A stale poller can. A
769/// database it cannot read cannot.
770///
771/// Re-registration is automatic: the proxy keeps probing and routes again the
772/// moment the check passes. That is what makes a 503 recoverable without
773/// intervention — not a restart, which never comes.
774///
775/// The body is machine facts only — no user counts, no DIDs, no feed URLs — so
776/// it is publishable on the same terms as `/stats`. It is also the non-session
777/// diagnostic for an OAuth outage: when nobody can log in, `/admin/metrics`
778/// (which needs a live admin session) is exactly as unreachable as the thing it
779/// would diagnose, while this is reachable with `curl`.
780async fn health(State(state): State<AppState>) -> Response {
781    let now = chrono::Utc::now().timestamp();
782    let rh = &state.runtime_health;
783
784    use crate::runtime_health::DbProbe;
785    let db = match rh.begin_db_probe() {
786        // A probe is already in flight; report its predecessor rather than
787        // starting a second one. See `RuntimeHealth::begin_db_probe`.
788        Err(borrowed) => borrowed,
789        Ok(probe) => {
790            // **Spawned, so the probe cannot be cancelled by the caller.**
791            //
792            // Axum drops the handler future when a client disconnects. With the
793            // probe inline, that dropped it mid-flight and released the claim
794            // WITHOUT recording a verdict — which let an unauthenticated caller
795            // manufacture the no-verdict state on demand and freeze what every
796            // other caller, Fly's check included, reads. Running it detached
797            // means the verdict is always recorded and the claim is always
798            // released after it.
799            let pool = state.db.clone();
800            let task = tokio::spawn(async move {
801                // **`SELECT 1` was not a database probe.** It compiles to
802                // `Init/Integer/ResultRow/Halt` — there is no `OpenRead`, so it
803                // never touches a b-tree, never reads a page, and never consults
804                // the file. Against a corrupted database it returns success
805                // while every real query returns SQLITE_CORRUPT. Reading one row
806                // from a real table costs the same and actually proves what the
807                // check claims. `LIMIT 1` keeps it to a single page; an empty
808                // table still opens the b-tree root, which is the part that
809                // matters.
810                let verdict =
811                    match tokio::time::timeout(HEALTH_DB_TIMEOUT, health_db_probe(&pool)).await {
812                        Ok(Ok(_)) => DbProbe::Ok,
813                        // Coarse, not the raw error. An unauthenticated caller
814                        // learning exactly which failure it hit is an
815                        // attack-progress oracle; the detail belongs in the log,
816                        // which gets it here.
817                        Ok(Err(err)) => {
818                            warn!(%err, "health: database probe failed");
819                            DbProbe::Failed("unavailable".to_string())
820                        }
821                        Err(_) => {
822                            warn!(
823                                timeout_s = HEALTH_DB_TIMEOUT.as_secs(),
824                                "health: database probe timed out (pool exhausted?)"
825                            );
826                            DbProbe::Failed("timeout".to_string())
827                        }
828                    };
829                probe.record(verdict.clone());
830                verdict
831            });
832            // A panicking task drops the guard, which releases the claim without
833            // a verdict — the only remaining path to that state, and not one a
834            // caller can drive.
835            task.await.unwrap_or(DbProbe::Unknown)
836        }
837    };
838
839    let uptime = rh.uptime_secs(now);
840    let poller = if !rh.schedulers_enabled() {
841        // Not a fault. Dev runs and the seam tests disable the loops on purpose,
842        // and reporting that as "stale" would be a false alarm on every one.
843        "disabled".to_string()
844    } else {
845        match rh.secs_since_poll_tick(now) {
846            // "Never ticked" is benign right after boot and alarming well after
847            // it — so it is read against UPTIME, not left permanently benign.
848            None => match uptime {
849                Some(up) if up > HEALTH_FIRST_TICK_GRACE_SECS => {
850                    format!("stale never-ticked {up}s")
851                }
852                _ => "not-yet-ticked".to_string(),
853            },
854            Some(secs) if secs > health_tick_stale_secs(configured_poll_tick()) => {
855                format!("stale {secs}s")
856            }
857            Some(secs) => format!("ok {secs}s"),
858        }
859    };
860
861    // **Only a MEASURED failure fails the check.**
862    //
863    // `Unknown` means no probe has completed — a concurrent request arrived
864    // before the first one finished, or a previous owner was cancelled before
865    // recording. It is reported and returns 200, because an unmeasured database
866    // is not evidence of a broken one, and this endpoint is reachable by
867    // unauthenticated callers who can manufacture that state. Treating it as a
868    // failure handed them a lever on the only signal the platform acts on.
869    let mut body = String::new();
870    let status = match &db {
871        DbProbe::Ok => {
872            body.push_str(&format!("ok featherreader/{VERSION}\n"));
873            body.push_str("db: ok\n");
874            StatusCode::OK
875        }
876        // **Not `ok`.** The first token is the state, and this one is neither
877        // healthy nor failed. It used to print a line byte-identical to the
878        // healthy branch, which mattered because `fly.toml` tells operators to
879        // alert on the BODY for everything the status code deliberately ignores
880        // — so a monitor keying on `^ok` read green in exactly the state this
881        // enum exists to make visible.
882        DbProbe::Unknown => {
883            body.push_str(&format!("unknown featherreader/{VERSION}\n"));
884            body.push_str("db: unknown (no probe has completed yet)\n");
885            StatusCode::OK
886        }
887        DbProbe::Failed(why) => {
888            body.push_str(&format!("FAIL featherreader/{VERSION}\n"));
889            body.push_str(&format!("db: {why}\n"));
890            StatusCode::SERVICE_UNAVAILABLE
891        }
892    };
893    // Uptime answers the first question anyone asks about a container under a
894    // supervisor that tears the machine down whenever a child exits: is this
895    // thing restarting? Nothing else on any surface could tell you.
896    body.push_str(&format!(
897        "uptime: {}\n",
898        match uptime {
899            Some(secs) => format!("{secs}s"),
900            None => "unknown".to_string(),
901        }
902    ));
903    body.push_str(&format!("poller: {poller}\n"));
904    body.push_str(&format!(
905        "polling-paused: {}\n",
906        if rh.watermark_paused() { "yes" } else { "no" }
907    ));
908    // Deliberately NOT the measured database size. `/health` is the one path
909    // exempted from the Caddy origin lock, so it answers direct hits to the Fly
910    // IP that never passed Cloudflare — which caps what belongs here at the
911    // class of facts `/stats` already publishes to anyone. "Polling is paused"
912    // is that; the exact byte count is a precise internal number that adds
913    // nothing an operator cannot get from `/stats` or the logs.
914    body.push_str(&format!(
915        "backend: {}\n",
916        state.config.repo_backend.as_str()
917    ));
918    body.push_str(&format!(
919        "oauth-runtime: {}\n",
920        if state.oauth.is_some() {
921            "built"
922        } else {
923            "absent"
924        }
925    ));
926
927    // Never cached: a stale health response is worse than none, and Cloudflare
928    // sits in front of this.
929    let mut resp = (status, body).into_response();
930    if let Ok(hv) = header::HeaderValue::from_str("no-store") {
931        resp.headers_mut().insert(header::CACHE_CONTROL, hv);
932    }
933    resp
934}
935
936/// `GET /about` — the public-experiment page: the full disclaimer (experimental,
937/// no SLA, may pause anytime), the OSS / self-host pitch, and the tip link.
938/// Readable whether or not a session exists.
939///
940/// Optionally carries one quiet line about network adoption
941/// (`design/NETWORK-SPEC.md` §4.4). With `FEATHERREADER_SHOW_ADOPTION` off — the
942/// default — the handler issues **zero** queries and the page is byte-identical
943/// to what it was before the probe existed.
944async fn about(State(state): State<AppState>) -> Response {
945    let adoption = if state.config.show_adoption {
946        adoption_line(&state).await
947    } else {
948        None
949    };
950    render(&AboutTemplate {
951        version: VERSION,
952        repo_url: REPO_URL,
953        kofi_url: KOFI_URL,
954        adoption,
955    })
956}
957
958/// `POST /saved/:rkey/delete` — remove a saved record that has no local entry.
959///
960/// The normal star toggle is keyed on an entry id, which a PDS-only saved row
961/// does not have. This deletes the record straight from the repo by its rkey,
962/// and then clears any LOCAL star for the same article.
963///
964/// That second step is not belt-and-braces. "Has no local entry" is how the
965/// starred view classifies a record, and it decides that through `sub_ref` — so
966/// an article that really is cached, and really is starred, lands here whenever
967/// the reader has unsubscribed from its feed. Deleting only the record left
968/// `entry_state.starred = 1` behind: invisible, because the starred list is
969/// `sub_ref`-scoped too, until a resubscribe brought the star back with nothing
970/// in the PDS backing it. A reader who clicks "remove" gets it removed from both
971/// places it lives.
972async fn unsave_record(
973    State(state): State<AppState>,
974    headers: HeaderMap,
975    Path(rkey): Path<String>,
976) -> Response {
977    let Some(did) = current_did(&state, &headers).await else {
978        return (StatusCode::UNAUTHORIZED, "sign in first\n").into_response();
979    };
980
981    // Read the record's identity BEFORE deleting it — afterwards there is
982    // nothing left to learn it from. Best-effort: a failure here must not block
983    // the deletion the reader actually asked for, so it degrades to the old
984    // behaviour (record gone, local star possibly stale) and says so.
985    let identity = match state.repo().list_saved(&did).await {
986        Ok(records) => records
987            .into_iter()
988            .find(|(k, _)| *k == rkey)
989            .map(|(_, rec)| (rec.url, rec.entry_id)),
990        Err(err) => {
991            warn!(%err, %did, %rkey, "could not read the saved record before deleting it; \
992                                      a local star for the same article may survive");
993            None
994        }
995    };
996
997    match state.repo().remove_saved(&did, &rkey).await {
998        Ok(()) => info!(%did, %rkey, "removed a saved record with no cached entry"),
999        Err(err) => {
1000            warn!(%err, %did, %rkey, "could not remove the saved record");
1001            return (StatusCode::BAD_GATEWAY, "could not remove that item\n").into_response();
1002        }
1003    }
1004
1005    // Deliberately AFTER the delete: the PDS is the source of truth for what was
1006    // saved, so clearing the local star before knowing the record is gone would
1007    // be the desync in the other direction.
1008    if let Some((url, guid)) = identity {
1009        match store::clear_star_by_identity(&state.db, &did, Some(&url), guid.as_deref()).await {
1010            Ok(0) => {}
1011            Ok(n) => {
1012                info!(%did, %rkey, cleared = n, "cleared the local star for an unsaved record")
1013            }
1014            Err(err) => warn!(%err, %did, %rkey, "could not clear the local star after unsaving"),
1015        }
1016    }
1017    // htmx swaps the row out; a plain form post goes back to the starred list.
1018    if is_htmx(&headers) {
1019        return (StatusCode::OK, "").into_response();
1020    }
1021    Redirect::to("/?view=starred").into_response()
1022}
1023
1024/// What the poller is doing, as one word for `/stats`.
1025///
1026/// **Parity with `/health` is the point.** `polling_paused` alone reported
1027/// "running" for three different states including the two where nothing polls,
1028/// on the page added to answer exactly that. The first attempt at fixing it
1029/// added `off` and `starting` and claimed parity — but left out `stale`, so a
1030/// poll loop that ticked once at boot and then WEDGED still read as running.
1031/// That is the wedged-loop case `/health`'s heartbeat exists for, and the
1032/// original finding's exact shape surviving its own fix.
1033///
1034/// Shares the staleness threshold with `/health` rather than picking its own, so
1035/// the two pages cannot disagree about what "stale" means.
1036fn fetching_state(rh: &crate::runtime_health::RuntimeHealth, now_unix: i64) -> &'static str {
1037    if !rh.schedulers_enabled() {
1038        return "off";
1039    }
1040    // Checked before the pause: a wedged poller cannot clear a pause either, so
1041    // reporting "paused" would name the symptom and hide the cause.
1042    match rh.secs_since_poll_tick(now_unix) {
1043        None => {
1044            // Never ticked. Benign at boot, a dead loop long after — read
1045            // against uptime, exactly as `/health` does.
1046            match rh.uptime_secs(now_unix) {
1047                Some(up) if up > HEALTH_FIRST_TICK_GRACE_SECS => "stale",
1048                _ => "starting",
1049            }
1050        }
1051        Some(secs) if secs > health_tick_stale_secs(configured_poll_tick()) => "stale",
1052        _ if rh.watermark_paused() => "paused",
1053        _ => "running",
1054    }
1055}
1056
1057/// `GET /stats` — public poll health.
1058async fn stats(State(state): State<AppState>) -> Response {
1059    let now = chrono::Utc::now();
1060    let health = match store::poll_health(
1061        &state.db,
1062        &now.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
1063        &(now - chrono::Duration::hours(1)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
1064    )
1065    .await
1066    {
1067        Ok(health) => health,
1068        Err(err) => {
1069            warn!(%err, "could not compute poll health");
1070            return (StatusCode::INTERNAL_SERVER_ERROR, "stats unavailable\n").into_response();
1071        }
1072    };
1073
1074    // Percentage of a zero-feed instance is 100, not a divide-by-zero: a fresh
1075    // instance is not behind on anything.
1076    let polled_pct = if health.feeds_tracked == 0 {
1077        100
1078    } else {
1079        health.polled_last_hour * 100 / health.feeds_tracked
1080    };
1081
1082    render(&StatsTemplate {
1083        version: VERSION,
1084        repo_url: REPO_URL,
1085        kofi_url: KOFI_URL,
1086        feeds_tracked: health.feeds_tracked,
1087        polled_last_hour: health.polled_last_hour,
1088        polled_pct,
1089        overdue: health.overdue,
1090        last_poll: humanise_ago(health.last_poll_secs_ago),
1091        oldest_poll: if health.never_polled > 0 {
1092            "never".to_string()
1093        } else {
1094            humanise_ago(health.oldest_poll_secs_ago)
1095        },
1096        never_polled: health.never_polled,
1097        poll_interval_mins: state.config.poll_interval.as_secs() as i64 / 60,
1098        // **The two states that actually stop feeds updating.**
1099        //
1100        // Neither was visible anywhere. `overdue` and `polled_last_hour` move in
1101        // both and distinguish neither — and `overdue` moves the WRONG WAY for
1102        // backoff, since backoff is applied by pushing `next_poll` forward, so a
1103        // feed failing every fetch drops out of the backlog and makes the page
1104        // read healthier. Both of these are machine facts with no per-feed
1105        // detail, so they sit inside the page's stated contract.
1106        in_backoff: health.in_backoff,
1107        badly_broken: health.badly_broken,
1108        failure_kinds: health.failure_kinds,
1109        fetching: fetching_state(&state.runtime_health, now.timestamp()),
1110    })
1111}
1112
1113/// "3h 11m ago", or "never" when there has been no poll at all.
1114///
1115/// `None` must not render as `0` — on a fresh instance that would read as
1116/// "polled just now", which is the opposite of the truth.
1117fn humanise_ago(secs: Option<i64>) -> String {
1118    let Some(secs) = secs else {
1119        return "never".to_string();
1120    };
1121    match secs {
1122        s if s < 60 => format!("{s}s ago"),
1123        s if s < 3600 => format!("{}m ago", s / 60),
1124        s => format!("{}h {}m ago", s / 3600, (s % 3600) / 60),
1125    }
1126}
1127
1128/// The `/about` adoption line's data, or `None` (no successful probe yet, an
1129/// observation of zero, or a store failure).
1130///
1131/// A read failure degrades to `None` plus a `warn!` rather than propagating: the
1132/// probe is never allowed to affect the reader, and that rule applies at the
1133/// display end too — a locked or corrupt DB costs the About page one log line,
1134/// not a 500.
1135async fn adoption_line(state: &AppState) -> Option<AdoptionLine> {
1136    match store::latest_network_stat(&state.db, store::ADOPTION_STAT_KEY).await {
1137        // A legitimate zero renders nothing rather than a sad "0 accounts".
1138        Ok(Some(stat)) if stat.value > 0 => Some(AdoptionLine {
1139            repos: stat.value,
1140            truncated: stat.truncated,
1141            observed_on: stat
1142                .observed_at
1143                .split('T')
1144                .next()
1145                .unwrap_or_default()
1146                .to_string(),
1147        }),
1148        Ok(_) => None,
1149        Err(err) => {
1150            warn!(%err, "about: adoption stat read failed; omitting the line");
1151            None
1152        }
1153    }
1154}
1155
1156/// `GET /privacy` — the plain-language privacy page: no account/tracking, data
1157/// lives in the user's PDS, what the server caches, and the session-token
1158/// handling. A static render; readable whether or not a session exists.
1159async fn privacy() -> Response {
1160    render(&PrivacyTemplate {
1161        version: VERSION,
1162        repo_url: REPO_URL,
1163        kofi_url: KOFI_URL,
1164    })
1165}
1166
1167/// `GET /terms` — the terms of use: the experimental / as-is disclaimer,
1168/// acceptable use, the AGPL/self-host note, and the liability limitation. A
1169/// static render; readable whether or not a session exists.
1170async fn terms() -> Response {
1171    render(&TermsTemplate {
1172        version: VERSION,
1173        repo_url: REPO_URL,
1174        kofi_url: KOFI_URL,
1175    })
1176}
1177
1178// ---------------------------------------------------------------------------
1179// View models
1180// ---------------------------------------------------------------------------
1181
1182/// A feed as shown in the sidebar (title + its unread count + a stable scope key
1183/// and the PDS subscription rkey for management actions).
1184struct FeedView {
1185    /// PDS subscription rkey — addresses the record for rename/unsubscribe.
1186    rkey: String,
1187    /// Canonical feed URL — the sidebar filter key (`?feed=<url>`).
1188    url: String,
1189    title: String,
1190    unread: i64,
1191    /// Whether this feed is the currently-selected scope.
1192    selected: bool,
1193    /// The feed's current folder `at://` URI (from its subscription record), or
1194    /// `None` if un-foldered. Drives the pre-selected `<option>` in the manage
1195    /// rename row so an untouched folder dropdown does not silently un-folder the
1196    /// feed on save.
1197    folder: Option<String>,
1198}
1199
1200/// A folder grouping in the sidebar, sourced from the PDS `folder` records.
1201struct FolderView {
1202    /// PDS folder rkey — addresses the record for rename/delete.
1203    rkey: String,
1204    /// The folder's `at://` URI — the sidebar filter key (`?folder=<uri>`).
1205    uri: String,
1206    name: String,
1207    feeds: Vec<FeedView>,
1208    /// Whether this folder is the currently-selected scope.
1209    selected: bool,
1210}
1211
1212/// One entry as shown in the article list / after an htmx swap.
1213struct EntryRow {
1214    id: i64,
1215    title: String,
1216    feed_title: String,
1217    published: String,
1218    read: bool,
1219    starred: bool,
1220    /// The reader link href, already carrying the scope/view query so opening an
1221    /// entry and paging back stays within the list it came from.
1222    link: SafeLink,
1223    /// Whether the article itself is in this instance's cache.
1224    ///
1225    /// `false` for a saved record that exists in the reader's PDS but whose
1226    /// entry was never cached here — starred in another atproto reader, or
1227    /// starred here and since evicted. There is no local row, so the row has no
1228    /// usable `id`: it links straight out to the article and carries no
1229    /// mark-read control, because there is nothing local to mark.
1230    cached: bool,
1231    /// The PDS record key, for un-saving a row that has no local entry.
1232    rkey: String,
1233}
1234
1235/// A folder as an option in the "move feed to folder" select.
1236struct FolderOption {
1237    uri: String,
1238    name: String,
1239}
1240
1241/// The shared navigation "rail" model: the same DOM element is the
1242/// mobile drawer and the desktop sidebar, so every chrome page (list / reader /
1243/// manage) renders it from this one struct. Feed management lives on `/manage`,
1244/// not here — the rail is navigation only.
1245struct Nav {
1246    /// `@handle` for the identity chip (falls back to the DID's tail).
1247    handle: String,
1248    /// Two-letter avatar initials for the identity chip.
1249    avatar: String,
1250    /// The active filter: `"unread" | "all" | "starred"` (drives `aria-current`).
1251    view: String,
1252    /// The scope query suffix (`feed=…` / `folder=…`) carried onto filter links,
1253    /// empty for the unscoped "everything" views.
1254    scope_qs: String,
1255    /// Folders (each with its feeds) then un-foldered feeds, for the rail lists.
1256    /// Per-feed `selected` flags drive the rail's feed `aria-current`.
1257    folders: Vec<FolderView>,
1258    loose_feeds: Vec<FeedView>,
1259    /// Whether the "Manage feeds" rail tool is the current page.
1260    manage_active: bool,
1261}
1262
1263/// The reader index (`GET /`).
1264#[derive(Template)]
1265#[template(path = "index.html")]
1266struct IndexTemplate {
1267    version: &'static str,
1268    repo_url: &'static str,
1269    kofi_url: &'static str,
1270    flash: String,
1271    /// The shared rail (drawer + desktop sidebar) navigation model.
1272    nav: Nav,
1273    /// The article list for the selected scope + view.
1274    entries: Vec<EntryRow>,
1275    /// The list heading (the selected view/feed/folder name).
1276    heading: String,
1277    /// Whether a feed scope is active (enables per-feed mark-all-read).
1278    feed_scope: Option<String>,
1279    /// Total CACHED entries in this scope + view across ALL pages. The count used
1280    /// to be `entries.len()`, which was the same number only because the list was
1281    /// unpaged — the thing this change exists to stop.
1282    ///
1283    /// The pager is derived from this, so it must not include the uncached PDS
1284    /// rows below: they are appended to the last page rather than paged, and
1285    /// counting them here advertised a page the clamp could never reach.
1286    total: i64,
1287    /// How many of `total` are PDS saved records the cache cannot show.
1288    ///
1289    /// A subset of `total`, not an addition to it — the heading says "N entries
1290    /// (M saved elsewhere)". An earlier version rendered "N entries, plus M",
1291    /// which double counted once `total` started including them, against an M
1292    /// that had become page-local in the same commit while the template stayed
1293    /// put.
1294    uncached_total: i64,
1295    /// 1-based current page.
1296    page: i64,
1297    /// Total pages, at least 1 (an empty list is page 1 of 1).
1298    page_count: i64,
1299    /// Link to the previous (newer) page, or `None` on the first.
1300    prev_href: Option<String>,
1301    /// Link to the next (older) page, or `None` on the last.
1302    next_href: Option<String>,
1303}
1304
1305/// The feed-management page (`GET /manage`) — subscribe / your-feeds / OPML.
1306#[derive(Template)]
1307#[template(path = "manage.html")]
1308struct ManageTemplate {
1309    version: &'static str,
1310    repo_url: &'static str,
1311    kofi_url: &'static str,
1312    flash: String,
1313    nav: Nav,
1314    /// All folders as move-targets for the subscribe folder select.
1315    folder_options: Vec<FolderOption>,
1316    /// Folders (each with feeds) + loose feeds, for the "Your feeds" list.
1317    folders: Vec<FolderView>,
1318    loose_feeds: Vec<FeedView>,
1319}
1320
1321/// The optional one-line adoption fact at the bottom of `/about`
1322/// (`design/NETWORK-SPEC.md` §4.4). `None` whenever the display flag is off, no
1323/// probe has succeeded yet, or the read failed — the line then simply does not
1324/// render.
1325struct AdoptionLine {
1326    /// Repos a relay has indexed as holding the subscription collection.
1327    repos: i64,
1328    /// The probe hit its page cap, so the copy must say "at least".
1329    truncated: bool,
1330    /// Observation date, `YYYY-MM-DD` (UTC), sliced from the stored RFC3339 stamp.
1331    observed_on: String,
1332}
1333
1334/// The public-experiment `/about` page — disclaimer + OSS pitch + tip link, plus
1335/// the optional adoption line.
1336#[derive(Template)]
1337#[template(path = "about.html")]
1338struct AboutTemplate {
1339    version: &'static str,
1340    repo_url: &'static str,
1341    kofi_url: &'static str,
1342    adoption: Option<AdoptionLine>,
1343}
1344
1345/// The public `/stats` page — is the poller keeping up?
1346///
1347/// Aggregate only, deliberately. It is published to anyone, so it carries no
1348/// user counts and no per-feed detail: a reader does not need to know how many
1349/// people use an instance or which feeds are failing. What it does answer is the
1350/// question that decides whether an instance can take more readers — whether the
1351/// poller is servicing the feeds it already has.
1352///
1353/// The counts below are aggregate machine facts, which is why they fit that
1354/// contract: "12 feeds are in backoff" names no feed and no reader, while
1355/// answering the question the page was previously unable to answer at all.
1356#[derive(Template)]
1357#[template(path = "stats.html")]
1358struct StatsTemplate {
1359    version: &'static str,
1360    repo_url: &'static str,
1361    kofi_url: &'static str,
1362    feeds_tracked: i64,
1363    polled_last_hour: i64,
1364    polled_pct: i64,
1365    overdue: i64,
1366    last_poll: String,
1367    oldest_poll: String,
1368    never_polled: i64,
1369    poll_interval_mins: i64,
1370    /// Feeds in error backoff. Invisible before, and excluded from `overdue`.
1371    in_backoff: i64,
1372    /// Of those, the ones retried hours apart rather than minutes. **Not
1373    /// "effectively dead"** — see `store::BADLY_BROKEN_ERRORS`; they recover on
1374    /// their next successful poll, and most of this instance's did.
1375    badly_broken: i64,
1376    /// Failing feeds by cause, descending — counts only, never which feed.
1377    failure_kinds: Vec<(String, i64)>,
1378    /// What the poller is actually doing: `running`, `paused` (at the size
1379    /// watermark), `starting` (no tick completed yet) or `off` (schedulers
1380    /// disabled). Three of those four used to render as "running".
1381    fetching: &'static str,
1382}
1383
1384/// The public `/privacy` page — what the server holds vs. what lives in the
1385/// user's PDS. Carries the same `repo_url`/`kofi_url`/`version` the shared
1386/// footer include needs.
1387#[derive(Template)]
1388#[template(path = "privacy.html")]
1389struct PrivacyTemplate {
1390    version: &'static str,
1391    repo_url: &'static str,
1392    kofi_url: &'static str,
1393}
1394
1395/// The public `/terms` page — the as-is / no-warranty terms of use. Carries the
1396/// same fields the shared footer include needs.
1397#[derive(Template)]
1398#[template(path = "terms.html")]
1399struct TermsTemplate {
1400    version: &'static str,
1401    repo_url: &'static str,
1402    kofi_url: &'static str,
1403}
1404
1405/// The signed-out landing page (`GET /` with no session) — the public front
1406/// door at feather-reader.com. A static render, no session required.
1407#[derive(Template)]
1408#[template(path = "landing.html")]
1409struct LandingTemplate {
1410    version: &'static str,
1411    repo_url: &'static str,
1412    crates_url: &'static str,
1413    kofi_url: &'static str,
1414}
1415
1416/// The single-entry reader view (`GET /entries/:id`).
1417#[derive(Template)]
1418#[template(path = "entry.html")]
1419struct EntryTemplate {
1420    version: &'static str,
1421    repo_url: &'static str,
1422    kofi_url: &'static str,
1423    nav: Nav,
1424    id: i64,
1425    title: String,
1426    feed_title: String,
1427    author: Option<String>,
1428    published: String,
1429    /// The entry's own link, for `entry.html`'s two `href`s.
1430    ///
1431    /// `Option<SafeLink>`, not `Option<String>`: the column it comes from holds
1432    /// a remote feed's `<link>`. Ingest scheme-checks it, but that guard is a
1433    /// long way from the `href` and holds only while every future writer to
1434    /// `entries.url` remembers to go through `feed.rs` — the same procedural
1435    /// defence that, on the saved-record row, turned out to be deletable with
1436    /// all 679 tests still green. `None` is the refusal: the template's
1437    /// no-URL branch already renders a disabled open-original button.
1438    url: Option<SafeLink>,
1439    content_html: Option<String>,
1440    read: bool,
1441    starred: bool,
1442    /// The query string to carry the reading context back to the list.
1443    back_qs: String,
1444    /// Prev/next entry ids within the current list, for keyboard/paging nav.
1445    prev_id: Option<i64>,
1446    next_id: Option<i64>,
1447    /// Rendered inline (not an out-of-band swap fragment): always `false` here.
1448    oob: bool,
1449}
1450
1451/// The htmx swap fragment for a single entry row (`entry_row.html`).
1452#[derive(Template)]
1453#[template(path = "entry_row.html")]
1454struct EntryRowTemplate {
1455    e: EntryRow,
1456}
1457
1458/// The reader's action-bar fragment (`entry_actionbar.html`) returned as an
1459/// out-of-band swap after a mark-read / star toggle FROM THE READER, so the
1460/// button's hidden value + `aria-pressed` update in place (the reader `<li>`
1461/// isn't in the DOM to swap, unlike the list view's `entry_row.html`).
1462#[derive(Template)]
1463#[template(path = "entry_actionbar.html")]
1464struct EntryActionBarTemplate {
1465    id: i64,
1466    read: bool,
1467    starred: bool,
1468    /// Emit the `hx-swap-oob` attribute: `true` for the handler's OOB response.
1469    oob: bool,
1470}
1471
1472/// The login stub (`GET /login`).
1473#[derive(Template)]
1474#[template(path = "login.html")]
1475struct LoginTemplate {
1476    repo_url: &'static str,
1477    error: String,
1478    /// A neutral/success banner (e.g. the post-delete "signed out" confirmation),
1479    /// distinct from `error`. Empty renders nothing.
1480    flash: String,
1481}
1482
1483/// The closed-beta invite-redeem page (`GET /beta/redeem`).
1484#[derive(Template)]
1485#[template(path = "beta_redeem.html")]
1486struct BetaRedeemTemplate {
1487    repo_url: &'static str,
1488    error: String,
1489    /// When true the seat cap is full: hide the form and show the "capacity
1490    /// full — try self-hosting" message instead.
1491    capacity_full: bool,
1492}
1493
1494// ---------------------------------------------------------------------------
1495// Rendering + error helpers
1496// ---------------------------------------------------------------------------
1497
1498/// Render an askama template into an HTML response, mapping a render failure to
1499/// a `500` rather than panicking (no `unwrap` in the request path).
1500fn render<T: Template>(tmpl: &T) -> Response {
1501    match tmpl.render() {
1502        Ok(body) => Html(body).into_response(),
1503        Err(err) => {
1504            warn!(%err, "template render failed");
1505            (StatusCode::INTERNAL_SERVER_ERROR, "template render error").into_response()
1506        }
1507    }
1508}
1509
1510/// A minimal web error type so handlers can `?`-propagate `anyhow` failures and
1511/// still return an `impl IntoResponse`. Renders as a `500` with a short message
1512/// by default; a handler may override the status (e.g. `413` for an over-cap
1513/// upload) via [`WebError::with_status`].
1514struct WebError {
1515    err: anyhow::Error,
1516    status: StatusCode,
1517}
1518
1519impl<E: Into<anyhow::Error>> From<E> for WebError {
1520    fn from(err: E) -> Self {
1521        WebError {
1522            err: err.into(),
1523            status: StatusCode::INTERNAL_SERVER_ERROR,
1524        }
1525    }
1526}
1527
1528impl WebError {
1529    /// Attach an explicit HTTP status to render instead of the default `500`.
1530    fn with_status(err: impl Into<anyhow::Error>, status: StatusCode) -> Self {
1531        WebError {
1532            err: err.into(),
1533            status,
1534        }
1535    }
1536}
1537
1538impl IntoResponse for WebError {
1539    fn into_response(self) -> Response {
1540        warn!(error = %self.err, status = %self.status, "request failed");
1541        let body = if self.status == StatusCode::INTERNAL_SERVER_ERROR {
1542            "internal error"
1543        } else {
1544            self.status.canonical_reason().unwrap_or("error")
1545        };
1546        (self.status, body).into_response()
1547    }
1548}
1549
1550/// Map an axum [`MultipartError`] to a [`WebError`] that preserves the error's
1551/// own HTTP status. When a request exceeds the route's `DefaultBodyLimit` the
1552/// multipart extractor reports `413 Payload Too Large`; a malformed body reports
1553/// `400`. Either way this avoids collapsing the failure into a generic `500`.
1554fn multipart_response(err: axum::extract::multipart::MultipartError) -> WebError {
1555    let status = err.status();
1556    WebError::with_status(err, status)
1557}
1558
1559/// A short, human display of a feed/site title for the sidebar/list, falling
1560/// back to the host of a URL and finally to the raw string.
1561fn display_title(title: Option<&str>, url: &str) -> String {
1562    if let Some(t) = title {
1563        let t = t.trim();
1564        if !t.is_empty() {
1565            return t.to_string();
1566        }
1567    }
1568    url::Url::parse(url)
1569        .ok()
1570        .and_then(|u| u.host_str().map(str::to_string))
1571        .unwrap_or_else(|| url.to_string())
1572}
1573
1574/// A display `@handle` for the identity chip: the stored handle if present,
1575/// else the tail of the DID so the chip is never empty.
1576fn display_handle(handle: Option<&str>, did: &str) -> String {
1577    match handle {
1578        Some(h) if !h.trim().is_empty() => format!("@{}", h.trim().trim_start_matches('@')),
1579        _ => did.rsplit(':').next().unwrap_or(did).to_string(),
1580    }
1581}
1582
1583/// Two-letter, lowercase avatar initials from a handle/DID.
1584fn avatar_initials(handle: Option<&str>, did: &str) -> String {
1585    let source = handle
1586        .map(|h| h.trim().trim_start_matches('@'))
1587        .filter(|h| !h.is_empty())
1588        .unwrap_or_else(|| did.rsplit(':').next().unwrap_or(did));
1589    let letters: String = source
1590        .chars()
1591        .filter(|c| c.is_alphanumeric())
1592        .take(2)
1593        .collect::<String>()
1594        .to_lowercase();
1595    if letters.is_empty() {
1596        "fr".to_string()
1597    } else {
1598        letters
1599    }
1600}
1601
1602/// Trim a stored RFC3339 timestamp down to the `YYYY-MM-DD` date for calm,
1603/// low-noise display. Falls back to the raw string if it doesn't look like one.
1604fn display_date(published: Option<&str>) -> String {
1605    // CHARACTERS, not bytes. `p[..10]` panics when byte 10 lands inside a
1606    // multi-byte character, and every caller used to pass a timestamp the feed
1607    // parser had produced. The saved-record path passes `createdAt` straight off
1608    // a PDS record, which the lexicon types as a bare string with no validation
1609    // — written by whatever atproto client the reader used. A `createdAt` of
1610    // "日本語日本語日本" took down the whole starred view, and there is no
1611    // catch-panic layer in the stack, so the page stayed down until the record
1612    // was removed from the very view that would not render.
1613    match published {
1614        Some(p) => p.chars().take(10).collect(),
1615        None => String::new(),
1616    }
1617}
1618
1619/// Percent-encode a value for use in a query string (RFC 3986 unreserved kept).
1620/// Small and dependency-free — the `url` crate's form-encoding isn't exposed for
1621/// a bare value, and this keeps the scope-preserving links honest.
1622fn qenc(s: &str) -> String {
1623    let mut out = String::with_capacity(s.len() * 3);
1624    for b in s.bytes() {
1625        match b {
1626            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
1627                out.push(b as char)
1628            }
1629            _ => out.push_str(&format!("%{b:02X}")),
1630        }
1631    }
1632    out
1633}
1634
1635// ---------------------------------------------------------------------------
1636// Reader: index
1637// ---------------------------------------------------------------------------
1638
1639/// Query for `GET /` — the scope + view selector.
1640#[derive(Debug, Deserialize, Default)]
1641struct IndexQuery {
1642    /// Filter to a single feed by its canonical URL.
1643    #[serde(default)]
1644    feed: Option<String>,
1645    /// Filter to a folder by its `at://` URI (shows every feed in the folder).
1646    #[serde(default)]
1647    folder: Option<String>,
1648    /// `unread` (default) | `all` | `starred`.
1649    #[serde(default)]
1650    view: Option<String>,
1651    /// 1-based page within the selected scope + view. Absent/0 means page 1.
1652    #[serde(default)]
1653    page: Option<u32>,
1654    /// Optional flash message (e.g. after an action redirect).
1655    #[serde(default)]
1656    flash: Option<String>,
1657}
1658
1659/// Rows per page in the reader's list views.
1660///
1661/// The list projection no longer carries article bodies ([`store::EntryListRow`]),
1662/// so a page is on the order of tens of kilobytes rather than the tens or
1663/// hundreds of megabytes an unbounded list of full entries could reach. The page
1664/// bound is the second half of that fix: without it, a reader with a long
1665/// backlog still decides how much memory a single request allocates.
1666const ENTRIES_PER_PAGE: i64 = 100;
1667
1668/// How many pages `total` entries occupy. An empty list is page 1 of 1, so the
1669/// pager reads "1 / 1" rather than "1 / 0".
1670fn page_count_for(total: i64) -> i64 {
1671    ((total + ENTRIES_PER_PAGE - 1) / ENTRIES_PER_PAGE).max(1)
1672}
1673
1674/// Ceiling on the reader's prev/next id list.
1675///
1676/// Unlike the page above, this genuinely spans the whole list — prev/next is the
1677/// reader's position within it — so it is bounded by count rather than paged. At
1678/// 8 bytes per id this is ~40 KB at the cap. Past it the neighbour links stop
1679/// resolving; the article itself still opens, and the list view still pages.
1680const PREV_NEXT_MAX: i64 = 5_000;
1681
1682/// Ceiling on the cached-starred identity set matched against PDS saved records.
1683///
1684/// Deliberately generous: under-reading this set makes a cached article look
1685/// uncached, and an uncached starred row's button deletes the PDS RECORD rather
1686/// than un-starring the entry. Truncating here would change what a click
1687/// destroys, so the cap exists only as a backstop against an absurd starred
1688/// count, not as a routine bound.
1689const STARRED_IDENTITY_MAX: i64 = 20_000;
1690
1691/// Most uncached PDS saved records this handler will hold in memory for one
1692/// request.
1693///
1694/// **A memory bound, not a visibility bound.** These rows are PAGED alongside
1695/// the cached entries, so `ENTRIES_PER_PAGE` decides how many are rendered and
1696/// this only caps how many are collected before slicing. An earlier version used
1697/// it to cap what was SHOWN, which left everything past it invisible and —
1698/// because the un-save control lives on the row, and nothing else in the app
1699/// lists these — unremovable.
1700///
1701/// Well above the PDS list ceiling's practical reach for one reader, so a reader
1702/// meeting it has thousands of saved records and gets a logged, ordered prefix
1703/// rather than a failure.
1704const MAX_UNCACHED_SAVED_ROWS: usize = 5_000;
1705
1706/// A subscription resolved against the local cache: the PDS record + its
1707/// (possibly-missing) cached feed row.
1708struct ResolvedSub {
1709    rkey: String,
1710    sub: Subscription,
1711    feed: Option<store::Feed>,
1712}
1713
1714/// Pull the user's subscriptions (source of truth = PDS), ensure each has a
1715/// local cache row so unread counts work, and return them resolved. Best-effort
1716/// on the sidecar: a failure falls back to the local cache alone.
1717async fn resolve_subscriptions(state: &AppState, did: &str) -> Vec<ResolvedSub> {
1718    let pool = &state.db;
1719    let subs = match state.repo().list_subscriptions_sorted(did).await {
1720        Ok(s) => s,
1721        Err(err) => {
1722            warn!(%err, %did, "could not list PDS subscriptions; showing this DID's cached subscriptions only");
1723            // Fail CLOSED: the PDS is the source of truth for what this DID
1724            // follows. When it is unreachable we must NOT widen the caller's
1725            // authorization surface. Serve from the DID's OWN last-known
1726            // `sub_ref` projection (its own feeds, possibly stale) and leave
1727            // `sub_ref` untouched — never synthesize from every cached feed,
1728            // which would grant cross-tenant read+mutate during any outage.
1729            // A DB failure here is NOT the same as "this DID follows nothing",
1730            // but `unwrap_or_default` rendered it as exactly that: an empty
1731            // sidebar and an empty reader, which arrives as "all my feeds
1732            // vanished". It still degrades to empty — there is nothing better to
1733            // show — but it says so, so the support ticket and the log line can
1734            // be matched up.
1735            let feeds = store::feeds_for_did(pool, did).await.unwrap_or_else(|err| {
1736                warn!(%err, %did, "the PDS is unreachable AND the local subscription \
1737                                   projection could not be read; rendering an EMPTY \
1738                                   feed list, which is not the same as having none");
1739                Vec::new()
1740            });
1741            return feeds
1742                .into_iter()
1743                .map(|f| ResolvedSub {
1744                    rkey: String::new(),
1745                    sub: Subscription::new(f.url.clone(), now_rfc3339()),
1746                    feed: Some(f),
1747                })
1748                .collect();
1749        }
1750    };
1751
1752    // **Deliberately NOT truncated to `max_subs_per_did`.**
1753    //
1754    // The PDS list is unbounded in practice — any client can write subscription
1755    // records, and only the 20,000-record list ceiling stops it — and the first
1756    // attempt at bounding it truncated the list right here. That was the wrong
1757    // place: `sync_sub_refs` below writes `sub_ref` from exactly this set, and
1758    // `sub_ref` is THE per-DID authorization hook, so dropping entries silently
1759    // removed the reader's ability to read OR mutate those feeds. A query-shape
1760    // problem would have become an access problem.
1761    //
1762    // The shape problem was the scope filter emitting one SQL placeholder per
1763    // feed; `store::list_query_sql` now passes the whole set as a single
1764    // `json_each` bind, so there is no size to defend against here and nothing
1765    // to truncate. `max_subs_per_did` stays what it is — a policy cap on ADDING
1766    // feeds — rather than becoming a silent read-time filter.
1767    let mut out = Vec::with_capacity(subs.len());
1768    for (rkey, sub) in subs {
1769        let feed = match store::get_feed_by_url(pool, &sub.url).await {
1770            Ok(Some(f)) => Some(f),
1771            Ok(None) => {
1772                // `sub.url` came out of an atproto record. The lexicon is open —
1773                // ANY client can write a subscription into a user's repo — so
1774                // this is untrusted input on the hot path of `GET /`, and it was
1775                // being stored with none of the three checks the add and import
1776                // paths apply. Two of those are capacity ceilings; this one is
1777                // the invariant in `FeedPrivacy`'s doc comment, which promises a
1778                // private feed URL is "never stored". Writing a
1779                // `…/feed/private/<token>` into the SHARED `feeds` table breaks
1780                // that promise even though `net::guarded_get` still refuses to
1781                // fetch it.
1782                if !feed::is_storable_feed_url(&sub.url, state.config.standard_site)
1783                    || feed::classify_feed_privacy(&sub.url).is_private()
1784                {
1785                    warn!(
1786                        %did,
1787                        "skipping cache row for a subscription URL that is private or not http(s)"
1788                    );
1789                    out.push(ResolvedSub {
1790                        rkey,
1791                        sub,
1792                        feed: None,
1793                    });
1794                    continue;
1795                }
1796                // Upsert a cache row so the sidebar reflects the real follow-list.
1797                //
1798                // A silent failure here is a support ticket with no evidence: no
1799                // `feeds` row means the poller never selects this subscription,
1800                // so the reader sees "I added a feed and it never updates" while
1801                // the PDS record looks perfect. Logged with the URL so the
1802                // failing subscription is identifiable.
1803                if let Err(err) = store::upsert_feed(
1804                    pool,
1805                    &store::NewFeed {
1806                        url: sub.url.clone(),
1807                        title: sub.title.clone(),
1808                        site_url: sub.site_url.clone(),
1809                        ..Default::default()
1810                    },
1811                )
1812                .await
1813                {
1814                    warn!(%err, url = %sub.url, %did, "could not cache a subscribed feed; \
1815                                                       it will not be polled");
1816                }
1817                store::get_feed_by_url(pool, &sub.url).await.ok().flatten()
1818            }
1819            Err(err) => {
1820                warn!(%err, url = %sub.url, "get_feed_by_url failed");
1821                None
1822            }
1823        };
1824        out.push(ResolvedSub { rkey, sub, feed });
1825    }
1826    // Mirror the caller's resolved subscription set into `sub_ref`, so every
1827    // scoped entry/feed read + read/star mutation authorizes against exactly
1828    // the feeds this DID follows right now. This is THE per-DID isolation hook.
1829    sync_sub_refs(pool, did, &out).await;
1830    out
1831}
1832
1833/// Refresh the `sub_ref` projection for `did` to exactly the feed ids present
1834/// in `subs`. Best-effort: a failure here only degrades the scoped reads (they
1835/// fail closed / show fewer rows), never leaks another user's entries.
1836async fn sync_sub_refs(pool: &store::Pool, did: &str, subs: &[ResolvedSub]) {
1837    let feed_ids: Vec<i64> = subs
1838        .iter()
1839        .filter_map(|s| s.feed.as_ref().map(|f| f.id))
1840        .collect();
1841    if let Err(err) = store::replace_sub_refs(pool, did, &feed_ids).await {
1842        warn!(%err, %did, "failed to sync sub_ref projection");
1843    }
1844}
1845
1846/// `GET /` — the reader. Renders the sidebar (folders + feeds from the PDS
1847/// records layer) and the article list for the selected scope + view.
1848async fn index(
1849    State(state): State<AppState>,
1850    headers: HeaderMap,
1851    Query(q): Query<IndexQuery>,
1852) -> Result<Response, WebError> {
1853    let user = match current_session(&state, &headers).await {
1854        Some(u) => u,
1855        // Signed out: serve the public landing page rather than bouncing to
1856        // /login. /login remains the entry point for the actual OAuth sign-in.
1857        None => {
1858            return Ok(render(&LandingTemplate {
1859                version: VERSION,
1860                repo_url: REPO_URL,
1861                crates_url: CRATES_URL,
1862                kofi_url: KOFI_URL,
1863            }))
1864        }
1865    };
1866    let did = user.did.clone();
1867    let pool = &state.db;
1868
1869    let subs = resolve_subscriptions(&state, &did).await;
1870
1871    // View: unread (default) | all | starred.
1872    let view = match q.view.as_deref() {
1873        Some("all") => "all",
1874        Some("starred") => "starred",
1875        _ => "unread",
1876    }
1877    .to_string();
1878    let list_view = list_view_of(q.view.as_deref());
1879
1880    // Which feed URLs are in scope?
1881    let scope_urls = scope_urls_for(&subs, q.feed.as_deref(), q.folder.as_deref());
1882    // …and the feed ids they resolve to. Scope is applied inside the query now,
1883    // so a page is a page of rows the reader will actually see. Filtering after
1884    // a `LIMIT` would have made pages arbitrarily short — sometimes empty — for
1885    // any scope narrower than the whole subscription list.
1886    let scope_ids = scoped_feed_ids(&subs, &scope_urls);
1887
1888    let feed_title_by_id = |id: i64| -> String {
1889        subs.iter()
1890            .find(|s| s.feed.as_ref().map(|f| f.id) == Some(id))
1891            .map(|s| {
1892                display_title(
1893                    s.sub
1894                        .title
1895                        .as_deref()
1896                        .or(s.feed.as_ref().and_then(|f| f.title.as_deref())),
1897                    &s.sub.url,
1898                )
1899            })
1900            .unwrap_or_default()
1901    };
1902
1903    // **One page of the chosen view, filtered, ordered and bounded in SQL.**
1904    //
1905    // All three views used to materialize every matching entry — `SELECT e.*`,
1906    // no `LIMIT`, article bodies included — and the "all" view additionally ran
1907    // one such query PER SUBSCRIBED FEED and merged the results in memory. None
1908    // of the row fields below read the body. See `store::EntryListRow`.
1909    // **Saved records the cache cannot show.**
1910    //
1911    // The starred view is built from local `entries`, so a saved record whose
1912    // article was never cached here is invisible — the case that matters is
1913    // starring in ANOTHER atproto reader, which is the portability the shared
1914    // lexicon exists for. Those rows are rendered from the PDS record alone.
1915    let mut uncached: Vec<EntryRow> = Vec::new();
1916    if view == "starred" {
1917        // **Match against every SUBSCRIBED cached starred entry, not `source`.**
1918        //
1919        // `source` has already been filtered by feed/folder. Matching against it
1920        // meant an entry that IS cached but sits outside the current filter
1921        // looked uncached — so it rendered as a "not cached" row whose star
1922        // button deletes the PDS RECORD instead of un-starring the entry. A
1923        // scope filter must not change what is destroyed. Paging is the same
1924        // hazard in a new form: matching against the visible PAGE would make
1925        // every cached article outside it look uncached. Hence a dedicated
1926        // identity query over the whole starred set — urls and guids only, no
1927        // bodies — rather than reusing `source`.
1928        //
1929        // One gap remains BY DESIGN, and is handled at the other end. This query
1930        // still carries the `sub_ref` predicate, so a starred, cached entry in a
1931        // feed the reader has UNSUBSCRIBED from is absent here and its record
1932        // renders as uncached. That is the right rendering — the article is no
1933        // longer part of any feed the reader follows, and the PDS record is what
1934        // still holds it — but it means the un-save button is the record-deleting
1935        // one. `unsave_record` therefore clears the local star too, so the two
1936        // stores agree however the row got classified. Dropping the predicate
1937        // here instead would have made the row link to `/entries/{id}`, which is
1938        // `sub_ref`-scoped and would 404.
1939        //
1940        // **Three ways this can be unusable, and all three fail CLOSED.** With an
1941        // incomplete identity set, a cached article looks uncached and renders an
1942        // un-save button that deletes the PDS RECORD. Showing no uncached rows
1943        // loses rows for one render; getting this wrong loses data permanently,
1944        // so every uncertain case suppresses them.
1945        let identities = match store::starred_identities(pool, &did, STARRED_IDENTITY_MAX).await {
1946            Ok(store::StarredIdentities::All(rows)) => Some(rows),
1947            // The cap is a memory backstop, and reaching it means the set is an
1948            // arbitrary subset. It used to return that subset with no way to
1949            // tell, so every starred article outside it got the destructive
1950            // button.
1951            Ok(store::StarredIdentities::Truncated) => {
1952                warn!(
1953                    %did,
1954                    cap = STARRED_IDENTITY_MAX,
1955                    "cached-starred set exceeded its cap; suppressing uncached saved rows \
1956                     rather than rendering record-deleting buttons for cached articles"
1957                );
1958                None
1959            }
1960            Err(err) => {
1961                warn!(%err, %did, "cached-starred identity lookup failed; \
1962                                    suppressing uncached saved rows this render");
1963                None
1964            }
1965        };
1966        // The escape hatch asks whether this DID has ANY cached starred entry —
1967        // not whether the current SCOPE does. `total` is narrowed by
1968        // `?feed=`/`?folder=` while the identity set spans every feed, so
1969        // comparing them waved the fail-closed condition through for any narrow
1970        // scope: a record whose `feedUrl` matched the filter while its cached
1971        // entry lived under another feed rendered as uncached.
1972        let identities_ok = identities.is_some();
1973        let identities = identities.unwrap_or_default();
1974        let cached_urls: std::collections::HashSet<&str> = identities
1975            .iter()
1976            .filter_map(|(url, _)| url.as_deref())
1977            .collect();
1978        let cached_guids: std::collections::HashSet<&str> =
1979            identities.iter().map(|(_, guid)| guid.as_str()).collect();
1980
1981        // Collected in full here, sliced per page later. They sort after every
1982        // cached row, so the two lists form one sequence that the pager walks —
1983        // see the slice below. Collected BEFORE the page is chosen because the
1984        // page count depends on how many there are.
1985        // Bounded like everything else on this page. These come from the PDS
1986        // (up to the 20,000-record list ceiling) and are appended whole to the
1987        // last page, so `ENTRIES_PER_PAGE` does not constrain them at all. The
1988        // cap is generous — a reader with more saved-elsewhere records than this
1989        // is not the case being designed for — but a response has to have a size
1990        // an operator can reason about.
1991        let mut uncached_dropped = 0usize;
1992        match state.repo().list_saved_sorted(&did).await {
1993            Ok(saved) if identities_ok => {
1994                for (rkey, item) in saved {
1995                    let known = cached_urls.contains(item.url.as_str())
1996                        || item
1997                            .entry_id
1998                            .as_deref()
1999                            .is_some_and(|g| cached_guids.contains(g));
2000                    if known {
2001                        continue;
2002                    }
2003                    // And the scope filter applies to these rows too. Without
2004                    // it, `?feed=X` still listed saved records from every other
2005                    // feed — the filter silently did nothing for them.
2006                    if let Some(urls) = &scope_urls {
2007                        match item.feed_url.as_deref() {
2008                            Some(feed_url) if urls.iter().any(|u| u == feed_url) => {}
2009                            // A saved record with no `feedUrl` cannot be placed
2010                            // in any feed's scope, so it belongs only to the
2011                            // unfiltered view.
2012                            _ => continue,
2013                        }
2014                    }
2015                    // **`safe_link` FIRST, and a failure no longer drops the row.**
2016                    //
2017                    // `item.url` is attacker-controlled — a saved record written
2018                    // by any client — and it lands in an `href`. Askama escapes
2019                    // HTML metacharacters but not SCHEMES, so `javascript:`
2020                    // survives escaping intact. This project already built the
2021                    // helper for exactly that, and `feed.rs` uses it on the
2022                    // equivalent link; this path was simply not routed through it.
2023                    //
2024                    // The real defect was what a failure DID: it `continue`d, so
2025                    // the row vanished entirely — no badge, no count, nothing —
2026                    // and the only trace was a `debug!` below any realistic
2027                    // filter. That makes the record unremovable FROM HERE, because
2028                    // the un-save button lives on the row; the reader has to open
2029                    // a different atproto client to get rid of it. A bad URL is a
2030                    // reason to withhold the LINK, not the row.
2031                    //
2032                    // The check also moved ABOVE the poll nudge. That is ordering
2033                    // hygiene rather than a fix: the nudge keys on `feed_url`, not
2034                    // on the URL being rejected here, and is already gated on the
2035                    // reader actually subscribing to that feed — so it was never
2036                    // reachable by an unusable `item.url`. Deciding whether a
2037                    // record is renderable before doing anything outbound on its
2038                    // behalf is simply the order that stays correct if either of
2039                    // those two facts later stops being true.
2040                    let link = SafeLink::external(&item.url);
2041                    if link.is_empty() {
2042                        warn!(
2043                            %did, %rkey,
2044                            "a saved record has an unusable URL; rendering it without a link \
2045                             so it can still be removed"
2046                        );
2047                    }
2048
2049                    // Opportunistic re-fetch: if the reader still subscribes to
2050                    // the feed, make it due now. If the article is still inside
2051                    // the feed's window the poller caches it normally and this
2052                    // row becomes a real entry on its own — no synthetic rows in
2053                    // the shared cache, which every subscriber would otherwise
2054                    // see as a content-less entry.
2055                    // **Bound the WORK, not just the response.** This check sat
2056                    // after the nudge and the `subs` scan below, so every render
2057                    // still walked all ≤20,000 PDS records, ran a subs-length
2058                    // string scan per record, and issued up to that many
2059                    // `mark_feed_due` round-trips on a 5-connection pool — then
2060                    // discarded everything past the cap. A cap that runs after
2061                    // the expensive part is a cap on the output only.
2062                    if uncached.len() >= MAX_UNCACHED_SAVED_ROWS {
2063                        uncached_dropped += 1;
2064                        continue;
2065                    }
2066                    if let Some(feed_url) = item.feed_url.as_deref() {
2067                        if subs.iter().any(|s| s.sub.url == feed_url) {
2068                            // Bounded to one nudge per feed per poll interval —
2069                            // see `mark_feed_due`. Unbounded, a reload loop here
2070                            // becomes outbound amplification.
2071                            let stale_before = (chrono::Utc::now()
2072                                - chrono::Duration::from_std(state.config.poll_interval)
2073                                    .unwrap_or_else(|_| chrono::Duration::hours(1)))
2074                            .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
2075                            if let Err(err) =
2076                                store::mark_feed_due(pool, feed_url, &stale_before).await
2077                            {
2078                                tracing::debug!(%err, %feed_url, "could not nudge a feed for a saved article");
2079                            }
2080                        }
2081                    }
2082                    uncached.push(EntryRow {
2083                        id: 0,
2084                        title: item
2085                            .title
2086                            .clone()
2087                            .filter(|t| !t.trim().is_empty())
2088                            // Falling back to the URL is fine for a link we are
2089                            // willing to render, and wrong for one we are not:
2090                            // it would put the exact string `safe_link` just
2091                            // rejected into the page as the record's name. The
2092                            // rkey is what the un-save button acts on, so it is
2093                            // the honest identifier for a row that has nothing
2094                            // else trustworthy to show.
2095                            .unwrap_or_else(|| {
2096                                if link.is_empty() {
2097                                    format!("Saved item {rkey}")
2098                                } else {
2099                                    item.url.clone()
2100                                }
2101                            }),
2102                        feed_title: item.feed_url.clone().unwrap_or_default(),
2103                        published: display_date(Some(&item.created_at)),
2104                        read: false,
2105                        starred: true,
2106                        // Empty = "render this row without an anchor". The
2107                        // template branches on it, so the rejected URL never
2108                        // reaches an `href` even as an escaped string.
2109                        link,
2110                        cached: false,
2111                        rkey,
2112                    });
2113                }
2114            }
2115            // Identity lookup was unusable — see the fail-closed note above.
2116            Ok(_) => {}
2117            Err(err) => warn!(%err, %did, "could not list saved records from the PDS"),
2118        }
2119        if uncached_dropped > 0 {
2120            warn!(
2121                %did,
2122                dropped = uncached_dropped,
2123                cap = MAX_UNCACHED_SAVED_ROWS,
2124                "more saved records than this instance will hold in one response; the \
2125                 rest are not reachable from here"
2126            );
2127        }
2128    }
2129
2130    // **One sequence, two sources.** The cached rows come from SQL, the uncached
2131    // PDS records follow them, and the pager walks the concatenation.
2132    //
2133    // The first version appended the uncached rows to the last page only and
2134    // kept them out of `total`, which left everything past a cap invisible AND
2135    // unremovable — the un-save button lives on the row, and there is no other
2136    // surface in the app that lists these. That is the same "unremovable FROM
2137    // HERE" hazard the `safe_link` fix above exists to prevent, reintroduced
2138    // forty lines later by a bound meant to protect memory.
2139    //
2140    // Paging the concatenation makes every record reachable and needs no cap on
2141    // what is RENDERED — one page is one page either way. The version before
2142    // that inflated `total` while clamping on the cached count, which advertised
2143    // a page the clamp could never reach; both numbers come from the same total
2144    // now, which is what makes that impossible rather than merely fixed.
2145    let total_cached =
2146        store::count_entries_for_view(pool, &did, list_view, scope_ids.as_deref()).await?;
2147    let uncached_len = uncached.len();
2148    let total = total_cached + uncached_len as i64;
2149    // Clamped to the range that exists. Past the end the list is empty, and the
2150    // empty state renders instead of the pager — which would strand a reader who
2151    // typed a page number, or who paged to the end and then marked entries read
2152    // out from under their own URL. Showing the last page is the answer to both.
2153    let page = i64::from(q.page.unwrap_or(1).max(1)).min(page_count_for(total));
2154    let offset = (page - 1) * ENTRIES_PER_PAGE;
2155    // Past the cached rows this returns nothing, which is exactly right: the
2156    // page is then made up entirely of uncached ones.
2157    let source = store::list_entries(
2158        pool,
2159        &did,
2160        list_view,
2161        scope_ids.as_deref(),
2162        ENTRIES_PER_PAGE,
2163        offset,
2164    )
2165    .await?;
2166    // **Both halves of the page are computed from the COUNT alone.**
2167    //
2168    // `total_cached` (a COUNT) and `source` (a SELECT) are separate unsynchronised
2169    // queries, so they can disagree about how many cached rows exist. Any part of
2170    // the page composition that reads `source.len()` inherits that disagreement.
2171    //
2172    // `cached_allotment` is this page's cached share according to the snapshot,
2173    // and it is what the uncached `skip`/`take` are derived from — so consecutive
2174    // pages tile the uncached list exactly, whichever way the count drifted.
2175    // `source` is then truncated to it only to avoid rendering rows the next page
2176    // will also claim.
2177    //
2178    // The previous version took `skip` from the count but `take` from
2179    // `source.len()`, which agreed only when the count UNDERSTATED. Overstating —
2180    // an un-star or a retention delete landing between the two queries — made
2181    // page N render `uncached[0..70]` while page N+1 rendered `uncached[50..80]`,
2182    // putting twenty rows, each carrying the record-DELETING un-save button, on
2183    // two pages at once. The comment claimed that shape was impossible; it was
2184    // merely rarer.
2185    let cached_allotment = (total_cached - offset).clamp(0, ENTRIES_PER_PAGE) as usize;
2186    let cached_here = cached_allotment.min(source.len());
2187    // Only compose when there is something to compose WITH. `uncached` is empty
2188    // on every view but `starred`, and truncating there just drops trailing rows
2189    // that no page then shows — the poller inserting between the COUNT and the
2190    // SELECT was enough to trigger it.
2191    let source = if uncached_len == 0 {
2192        &source[..]
2193    } else {
2194        &source[..cached_here]
2195    };
2196    let uncached_page: Vec<EntryRow> = {
2197        let skip = (offset - total_cached).max(0) as usize;
2198        let take = (ENTRIES_PER_PAGE as usize) - cached_allotment;
2199        uncached.into_iter().skip(skip).take(take).collect()
2200    };
2201    // This page's slice, used only to append below. The heading needs the
2202    // WHOLE-list figure, which is the set's size before slicing.
2203    let uncached_total = uncached_len as i64;
2204
2205    // The scope/view suffix carried onto every entry link (built once).
2206    let entry_scope_qs = {
2207        let mut parts = Vec::new();
2208        if let Some(f) = q.feed.as_deref() {
2209            parts.push(format!("feed={}", qenc(f)));
2210        }
2211        if let Some(f) = q.folder.as_deref() {
2212            parts.push(format!("folder={}", qenc(f)));
2213        }
2214        if view != "unread" {
2215            parts.push(format!("view={}", qenc(&view)));
2216        }
2217        parts.join("&")
2218    };
2219    let entries: Vec<EntryRow> = source
2220        .iter()
2221        .map(|e| EntryRow {
2222            id: e.id,
2223            title: e
2224                .title
2225                .clone()
2226                .filter(|t| !t.trim().is_empty())
2227                .unwrap_or_else(|| "(untitled)".to_string()),
2228            feed_title: feed_title_by_id(e.feed_id),
2229            published: display_date(e.published.as_deref()),
2230            // Both bits ride along on the row's own `entry_state` join now. They
2231            // used to be membership tests against the full unread and starred
2232            // sets, which is why those two lists were fetched in their entirety
2233            // on every render even when the page showed a hundred rows.
2234            read: e.read,
2235            starred: e.starred,
2236            link: SafeLink::entry(e.id, &entry_scope_qs),
2237            cached: true,
2238            rkey: String::new(),
2239        })
2240        .collect();
2241
2242    // The uncached slice for this page follows the cached rows.
2243    let mut entries = entries;
2244    entries.extend(uncached_page);
2245    let entries = entries;
2246
2247    let selected_feed = q.feed.as_deref();
2248    let selected_folder = q.folder.as_deref();
2249
2250    // Build the shared sidebar (folders + loose feeds, with unread counts).
2251    let (folder_views, loose_feeds, _folder_options) =
2252        build_sidebar(&state, &did, &subs, selected_feed, selected_folder).await;
2253
2254    // Heading + scope query-string suffix.
2255    let (heading, scope_qs) = if let Some(feed_url) = selected_feed {
2256        let name = subs
2257            .iter()
2258            .find(|s| s.sub.url == feed_url)
2259            .map(|s| {
2260                display_title(
2261                    s.sub
2262                        .title
2263                        .as_deref()
2264                        .or(s.feed.as_ref().and_then(|f| f.title.as_deref())),
2265                    &s.sub.url,
2266                )
2267            })
2268            .unwrap_or_else(|| display_title(None, feed_url));
2269        (name, format!("feed={}", qenc(feed_url)))
2270    } else if let Some(folder_uri) = selected_folder {
2271        let name = folder_views
2272            .iter()
2273            .find(|f| f.uri == folder_uri)
2274            .map(|f| f.name.clone())
2275            .unwrap_or_else(|| "Folder".to_string());
2276        (name, format!("folder={}", qenc(folder_uri)))
2277    } else {
2278        let h = match view.as_str() {
2279            "all" => "All",
2280            "starred" => "Starred",
2281            _ => "Unread",
2282        };
2283        (h.to_string(), String::new())
2284    };
2285
2286    let feed_scope = selected_feed.map(str::to_string);
2287    let nav = build_nav(&user, &view, scope_qs, folder_views, loose_feeds, false);
2288
2289    // Pager links. `entry_scope_qs` already carries feed/folder/view, so the
2290    // page number is the only thing appended — which keeps a paged link
2291    // identical to an unpaged one in every other respect.
2292    let page_href = |n: i64| -> String {
2293        let mut parts = Vec::new();
2294        if !entry_scope_qs.is_empty() {
2295            parts.push(entry_scope_qs.clone());
2296        }
2297        if n > 1 {
2298            parts.push(format!("page={n}"));
2299        }
2300        if parts.is_empty() {
2301            "/".to_string()
2302        } else {
2303            format!("/?{}", parts.join("&"))
2304        }
2305    };
2306    let prev_href = (page > 1).then(|| page_href(page - 1));
2307    let next_href = (page * ENTRIES_PER_PAGE < total).then(|| page_href(page + 1));
2308
2309    let tmpl = IndexTemplate {
2310        version: VERSION,
2311        repo_url: REPO_URL,
2312        kofi_url: KOFI_URL,
2313        flash: q.flash.unwrap_or_default(),
2314        nav,
2315        entries,
2316        heading,
2317        feed_scope,
2318        total,
2319        // Whole-list figure, so it sits beside `total` without double counting.
2320        // The per-page slice is composed above and is not a heading number.
2321        uncached_total,
2322        page,
2323        page_count: page_count_for(total),
2324        prev_href,
2325        next_href,
2326    };
2327    Ok(render(&tmpl))
2328}
2329
2330/// Query for `GET /manage` — carries an optional flash after an action redirect.
2331#[derive(Debug, Deserialize, Default)]
2332struct ManageQuery {
2333    #[serde(default)]
2334    flash: Option<String>,
2335}
2336
2337/// `GET /manage` — the feed-management page. Renders the rail plus the subscribe
2338/// / your-feeds / OPML surfaces; the forms POST to the existing routes
2339/// (`/subscriptions`, `/folders`, `/opml`, …). A read/render route only — no
2340/// mutation logic of its own.
2341async fn manage(
2342    State(state): State<AppState>,
2343    headers: HeaderMap,
2344    Query(q): Query<ManageQuery>,
2345) -> Result<Response, WebError> {
2346    let user = match current_session(&state, &headers).await {
2347        Some(u) => u,
2348        None => return Ok(Redirect::to("/login").into_response()),
2349    };
2350    let did = user.did.clone();
2351
2352    let subs = resolve_subscriptions(&state, &did).await;
2353    let (folder_views, loose_feeds, folder_options) =
2354        build_sidebar(&state, &did, &subs, None, None).await;
2355
2356    // Clone the sidebar for the rail; the page body reuses folders/loose feeds.
2357    let nav = build_nav(
2358        &user,
2359        "unread",
2360        String::new(),
2361        folder_views.iter().map(clone_folder_view).collect(),
2362        loose_feeds.iter().map(clone_feed_view).collect(),
2363        true,
2364    );
2365
2366    let tmpl = ManageTemplate {
2367        version: VERSION,
2368        repo_url: REPO_URL,
2369        kofi_url: KOFI_URL,
2370        flash: q.flash.unwrap_or_default(),
2371        nav,
2372        folder_options,
2373        folders: folder_views,
2374        loose_feeds,
2375    };
2376    Ok(render(&tmpl))
2377}
2378
2379/// Shallow clone helpers so `/manage` can hand the same sidebar to both the rail
2380/// (`Nav`) and the page body without an extra DB round-trip.
2381fn clone_feed_view(f: &FeedView) -> FeedView {
2382    FeedView {
2383        rkey: f.rkey.clone(),
2384        url: f.url.clone(),
2385        title: f.title.clone(),
2386        unread: f.unread,
2387        selected: f.selected,
2388        folder: f.folder.clone(),
2389    }
2390}
2391
2392fn clone_folder_view(f: &FolderView) -> FolderView {
2393    FolderView {
2394        rkey: f.rkey.clone(),
2395        uri: f.uri.clone(),
2396        name: f.name.clone(),
2397        feeds: f.feeds.iter().map(clone_feed_view).collect(),
2398        selected: f.selected,
2399    }
2400}
2401
2402/// The set of feed URLs a scope covers: `Some([one url])` for a single-feed
2403/// scope, `Some([urls…])` for a folder (its member feeds), or `None` for the
2404/// unscoped "everything" view. A folder scope takes the feed scope when both are
2405/// somehow present (feed wins, matching the query precedence elsewhere).
2406fn scope_urls_for(
2407    subs: &[ResolvedSub],
2408    feed: Option<&str>,
2409    folder: Option<&str>,
2410) -> Option<Vec<String>> {
2411    if let Some(feed_url) = feed {
2412        Some(vec![feed_url.to_string()])
2413    } else {
2414        folder.map(|folder_uri| {
2415            subs.iter()
2416                .filter(|s| s.sub.folder.as_deref() == Some(folder_uri))
2417                .map(|s| s.sub.url.clone())
2418                .collect()
2419        })
2420    }
2421}
2422
2423/// The `at://` URI for a folder record given the owner DID + rkey.
2424fn folder_uri(did: &str, rkey: &str) -> String {
2425    format!("at://{did}/{}/{rkey}", lexicon::nsid::FOLDER)
2426}
2427
2428/// Build the sidebar folder/loose-feed views (with per-feed unread counts) for a
2429/// DID — the shared source for both the reader index and the rail on every
2430/// chrome page. `selected_feed` / `selected_folder` drive `aria-current`.
2431async fn build_sidebar(
2432    state: &AppState,
2433    did: &str,
2434    subs: &[ResolvedSub],
2435    selected_feed: Option<&str>,
2436    selected_folder: Option<&str>,
2437) -> (Vec<FolderView>, Vec<FeedView>, Vec<FolderOption>) {
2438    let pool = &state.db;
2439    // Counted in SQL. This used to fetch every unread ENTRY — article bodies and
2440    // all — purely to `.filter().count()` them in Rust, on every page that
2441    // renders chrome, which made the sidebar the most frequently executed
2442    // instance of the unbounded-projection problem.
2443    let unread_counts = store::unread_counts_by_feed(pool, did)
2444        .await
2445        .unwrap_or_else(|err| {
2446            warn!(%err, %did, "sidebar unread counts failed; rendering zeroes");
2447            Default::default()
2448        });
2449    let folders = state
2450        .repo()
2451        .list_folders_sorted(did)
2452        .await
2453        .unwrap_or_default();
2454
2455    let unread_count = |feed_id: Option<i64>| -> i64 {
2456        feed_id
2457            .and_then(|id| unread_counts.get(&id).copied())
2458            .unwrap_or(0)
2459    };
2460    let mk_feed_view = |s: &ResolvedSub| FeedView {
2461        rkey: s.rkey.clone(),
2462        url: s.sub.url.clone(),
2463        title: display_title(
2464            s.sub
2465                .title
2466                .as_deref()
2467                .or(s.feed.as_ref().and_then(|f| f.title.as_deref())),
2468            &s.sub.url,
2469        ),
2470        unread: unread_count(s.feed.as_ref().map(|f| f.id)),
2471        selected: selected_feed == Some(s.sub.url.as_str()),
2472        folder: s.sub.folder.clone(),
2473    };
2474
2475    let mut folder_views = Vec::with_capacity(folders.len());
2476    for (rkey, folder) in &folders {
2477        let uri = folder_uri(did, rkey);
2478        let feeds: Vec<FeedView> = subs
2479            .iter()
2480            .filter(|s| s.sub.folder.as_deref() == Some(uri.as_str()))
2481            .map(mk_feed_view)
2482            .collect();
2483        folder_views.push(FolderView {
2484            rkey: rkey.clone(),
2485            uri: uri.clone(),
2486            name: folder.name.clone(),
2487            feeds,
2488            selected: selected_folder == Some(uri.as_str()),
2489        });
2490    }
2491
2492    let known_uris: std::collections::HashSet<String> =
2493        folders.iter().map(|(r, _)| folder_uri(did, r)).collect();
2494    let loose_feeds: Vec<FeedView> = subs
2495        .iter()
2496        .filter(|s| {
2497            s.sub
2498                .folder
2499                .as_deref()
2500                .map(|f| !known_uris.contains(f))
2501                .unwrap_or(true)
2502        })
2503        .map(mk_feed_view)
2504        .collect();
2505
2506    let folder_options: Vec<FolderOption> = folders
2507        .iter()
2508        .map(|(rkey, folder)| FolderOption {
2509            name: folder.name.clone(),
2510            uri: folder_uri(did, rkey),
2511        })
2512        .collect();
2513
2514    (folder_views, loose_feeds, folder_options)
2515}
2516
2517/// Assemble the shared rail [`Nav`] for a chrome page.
2518fn build_nav(
2519    user: &CurrentUser,
2520    view: &str,
2521    scope_qs: String,
2522    folders: Vec<FolderView>,
2523    loose_feeds: Vec<FeedView>,
2524    manage_active: bool,
2525) -> Nav {
2526    Nav {
2527        handle: display_handle(user.handle.as_deref(), &user.did),
2528        avatar: avatar_initials(user.handle.as_deref(), &user.did),
2529        view: view.to_string(),
2530        scope_qs,
2531        folders,
2532        loose_feeds,
2533        manage_active,
2534    }
2535}
2536
2537// ---------------------------------------------------------------------------
2538// Reader: single entry
2539// ---------------------------------------------------------------------------
2540
2541/// Query for `GET /entries/:id` — carries the reading context (scope + view) so
2542/// prev/next and "back" stay within the list the reader came from.
2543#[derive(Debug, Deserialize, Default)]
2544struct EntryQuery {
2545    #[serde(default)]
2546    feed: Option<String>,
2547    #[serde(default)]
2548    folder: Option<String>,
2549    #[serde(default)]
2550    view: Option<String>,
2551}
2552
2553/// `GET /entries/:id` — the clean reader view for one entry, with prev/next
2554/// within the current reading list.
2555async fn entry_view(
2556    State(state): State<AppState>,
2557    headers: HeaderMap,
2558    Path(id): Path<i64>,
2559    Query(q): Query<EntryQuery>,
2560) -> Result<Response, WebError> {
2561    let user = match current_session(&state, &headers).await {
2562        Some(u) => u,
2563        None => return Ok(Redirect::to("/login").into_response()),
2564    };
2565    let did = user.did.clone();
2566    let pool = &state.db;
2567
2568    // Resolve subscriptions FIRST: this refreshes the `sub_ref` projection so
2569    // the per-DID entry gate below authorizes against the caller's current PDS
2570    // subscription set (not another user's cached feeds).
2571    let subs = resolve_subscriptions(&state, &did).await;
2572
2573    let entry = match get_entry_by_id(pool, &did, id).await? {
2574        Some(e) => e,
2575        None => return Ok((StatusCode::NOT_FOUND, "entry not found").into_response()),
2576    };
2577
2578    let feed_title = feed_title_by_entry(pool, entry.feed_id).await;
2579
2580    let read = entry_is_read(pool, &did, id).await?;
2581    let starred = entry_is_starred(pool, &did, id).await?;
2582
2583    // Reconstruct the current list to compute prev/next, so paging in the reader
2584    // matches what the list showed.
2585    let (prev_id, next_id) = neighbors_in_scope(&state, &did, &q, id).await;
2586
2587    let back_qs = scope_query(&q);
2588
2589    let (folder_views, loose_feeds, _) =
2590        build_sidebar(&state, &did, &subs, q.feed.as_deref(), q.folder.as_deref()).await;
2591    let nav_view = match q.view.as_deref() {
2592        Some("all") => "all",
2593        Some("starred") => "starred",
2594        _ => "unread",
2595    };
2596    let nav = build_nav(
2597        &user,
2598        nav_view,
2599        back_qs.clone(),
2600        folder_views,
2601        loose_feeds,
2602        false,
2603    );
2604
2605    let tmpl = EntryTemplate {
2606        version: VERSION,
2607        repo_url: REPO_URL,
2608        kofi_url: KOFI_URL,
2609        nav,
2610        id: entry.id,
2611        title: entry
2612            .title
2613            .clone()
2614            .filter(|t| !t.trim().is_empty())
2615            .unwrap_or_else(|| "(untitled)".to_string()),
2616        feed_title,
2617        author: entry.author.clone().filter(|a| !a.trim().is_empty()),
2618        published: display_date(entry.published.as_deref()),
2619        url: entry.url.as_deref().and_then(SafeLink::external_opt),
2620        content_html: entry.content_html.clone(),
2621        read,
2622        starred,
2623        back_qs,
2624        prev_id,
2625        next_id,
2626        oob: false,
2627    };
2628    Ok(render(&tmpl))
2629}
2630
2631/// Compute the prev/next entry ids around `current` within the reader's current
2632/// scope + view, so the reader view can offer keyboard/paging navigation.
2633async fn neighbors_in_scope(
2634    state: &AppState,
2635    did: &str,
2636    q: &EntryQuery,
2637    current: i64,
2638) -> (Option<i64>, Option<i64>) {
2639    let idx_q = IndexQuery {
2640        feed: q.feed.clone(),
2641        folder: q.folder.clone(),
2642        view: q.view.clone(),
2643        // Neighbours span the whole list, not the page the reader arrived from.
2644        page: None,
2645        flash: None,
2646    };
2647    let ids = list_entry_ids(state, did, &idx_q).await;
2648    let pos = ids.iter().position(|&x| x == current);
2649    match pos {
2650        Some(p) => {
2651            let prev = if p > 0 { Some(ids[p - 1]) } else { None };
2652            let next = ids.get(p + 1).copied();
2653            (prev, next)
2654        }
2655        None => (None, None),
2656    }
2657}
2658
2659/// The ordered entry ids for a scope + view — the same ordering `index` renders,
2660/// used for reader prev/next. Best-effort; PDS failures degrade to local cache.
2661async fn list_entry_ids(state: &AppState, did: &str, q: &IndexQuery) -> Vec<i64> {
2662    let pool = &state.db;
2663    let subs = resolve_subscriptions(state, did).await;
2664
2665    let scope_urls = scope_urls_for(&subs, q.feed.as_deref(), q.folder.as_deref());
2666
2667    // Ids only, and bounded. This used to fetch whole entries — bodies included
2668    // — for all three views and then throw everything but `id` away; the "all"
2669    // branch additionally ran one unbounded query PER FEED and sorted the union
2670    // in memory. Scope is now a feed-id restriction inside the query, so the
2671    // database does the filtering and the ordering exactly once.
2672    store::list_entry_ids(
2673        pool,
2674        did,
2675        list_view_of(q.view.as_deref()),
2676        scoped_feed_ids(&subs, &scope_urls).as_deref(),
2677        PREV_NEXT_MAX,
2678    )
2679    .await
2680    .unwrap_or_else(|err| {
2681        warn!(%err, %did, "prev/next id list failed; the reader loses its neighbour links");
2682        Vec::new()
2683    })
2684}
2685
2686/// Map the `?view=` query value onto the store's list view. Anything
2687/// unrecognised is the unread default, matching `index`.
2688fn list_view_of(view: Option<&str>) -> store::ListView {
2689    match view {
2690        Some("all") => store::ListView::All,
2691        Some("starred") => store::ListView::Starred,
2692        _ => store::ListView::Unread,
2693    }
2694}
2695
2696/// Translate a feed/folder scope into the feed ids to restrict a list query to.
2697///
2698/// `None` means unscoped (every subscribed feed). `Some(&[])` means the scope
2699/// matched no local feed, which must return nothing rather than everything — so
2700/// the empty vec is deliberately preserved, not collapsed back into `None`.
2701fn scoped_feed_ids(subs: &[ResolvedSub], scope_urls: &Option<Vec<String>>) -> Option<Vec<i64>> {
2702    let urls = scope_urls.as_ref()?;
2703    Some(
2704        subs.iter()
2705            .filter(|s| urls.contains(&s.sub.url))
2706            .filter_map(|s| s.feed.as_ref().map(|f| f.id))
2707            .collect(),
2708    )
2709}
2710
2711/// Build a `?…` query string that preserves the reading scope + view for links.
2712fn scope_query(q: &EntryQuery) -> String {
2713    let mut parts = Vec::new();
2714    if let Some(f) = q.feed.as_deref() {
2715        parts.push(format!("feed={}", qenc(f)));
2716    }
2717    if let Some(f) = q.folder.as_deref() {
2718        parts.push(format!("folder={}", qenc(f)));
2719    }
2720    if let Some(v) = q.view.as_deref() {
2721        if v != "unread" {
2722            parts.push(format!("view={}", qenc(v)));
2723        }
2724    }
2725    parts.join("&")
2726}
2727
2728// ---------------------------------------------------------------------------
2729// Mark read / unread
2730// ---------------------------------------------------------------------------
2731
2732/// Form body for `POST /entries/:id/read`.
2733#[derive(Debug, Deserialize)]
2734struct ReadForm {
2735    #[serde(default)]
2736    read: Option<String>,
2737}
2738
2739/// `POST /entries/:id/read` — toggle an entry's read-state for the current DID.
2740async fn mark_read(
2741    State(state): State<AppState>,
2742    Path(id): Path<i64>,
2743    headers: HeaderMap,
2744    Form(form): Form<ReadForm>,
2745) -> Result<Response, WebError> {
2746    let did = match current_did(&state, &headers).await {
2747        Some(d) => d,
2748        None => return Ok(Redirect::to("/login").into_response()),
2749    };
2750    let pool = &state.db;
2751
2752    let read = matches!(
2753        form.read.as_deref(),
2754        Some("true") | Some("1") | Some("on") | None
2755    );
2756
2757    // Refresh the caller's `sub_ref` projection, then apply the AUTHORIZED
2758    // mutation: `mark_read` only writes when `did` subscribes to the entry's
2759    // feed. A non-subscriber gets a 404, never a mutation of someone else's
2760    // (or the shared cache's) state.
2761    resolve_subscriptions(&state, &did).await;
2762    if !store::mark_read(pool, &did, id, read).await? {
2763        return Ok((StatusCode::NOT_FOUND, "entry not found").into_response());
2764    }
2765
2766    if !is_htmx(&headers) {
2767        return Ok(Redirect::to("/").into_response());
2768    }
2769
2770    // The reader view swaps an out-of-band action-bar fragment (its `<li>` isn't
2771    // in the DOM), so its button's hidden value + aria-pressed update in place
2772    // and a second keypress can reverse the toggle. The list view swaps the row.
2773    if is_reader_request(&headers) {
2774        let starred = entry_is_starred(pool, &did, id).await?;
2775        return Ok(render(&EntryActionBarTemplate {
2776            id,
2777            read,
2778            starred,
2779            oob: true,
2780        }));
2781    }
2782
2783    let row = build_entry_row(pool, &did, id, Some(read)).await?;
2784    match row {
2785        Some(r) => Ok(render(&EntryRowTemplate { e: r })),
2786        None => Ok((StatusCode::NOT_FOUND, "entry not found").into_response()),
2787    }
2788}
2789
2790// ---------------------------------------------------------------------------
2791// Star / save
2792// ---------------------------------------------------------------------------
2793
2794/// Form body for `POST /entries/:id/star`.
2795#[derive(Debug, Deserialize)]
2796struct StarForm {
2797    #[serde(default)]
2798    starred: Option<String>,
2799}
2800
2801/// `POST /entries/:id/star` — star/unstar an entry.
2802///
2803/// Sets the local `starred` bit (fast working copy) and writes/removes a
2804/// `community.lexicon.rss.saved` record in the user's PDS (stars are worth
2805/// owning). The PDS write is best-effort — the local star still lands.
2806async fn toggle_star(
2807    State(state): State<AppState>,
2808    Path(id): Path<i64>,
2809    headers: HeaderMap,
2810    Form(form): Form<StarForm>,
2811) -> Result<Response, WebError> {
2812    let did = match current_did(&state, &headers).await {
2813        Some(d) => d,
2814        None => return Ok(Redirect::to("/login").into_response()),
2815    };
2816    let pool = &state.db;
2817
2818    let starred = matches!(
2819        form.starred.as_deref(),
2820        Some("true") | Some("1") | Some("on") | None
2821    );
2822
2823    // Refresh the caller's `sub_ref` projection, then apply the AUTHORIZED
2824    // mutation: `mark_starred` only writes when `did` subscribes to the entry's
2825    // feed. A non-subscriber gets a 404, never a mutation.
2826    resolve_subscriptions(&state, &did).await;
2827    if !store::mark_starred(pool, &did, id, starred).await? {
2828        return Ok((StatusCode::NOT_FOUND, "entry not found").into_response());
2829    }
2830
2831    // Reflect into the PDS saved-records collection. `get_entry_by_id` is scoped
2832    // to the caller's subscriptions, so this only ever acts on the caller's feed.
2833    if let Ok(Some(entry)) = get_entry_by_id(pool, &did, id).await {
2834        let entry_url = entry.url.clone().unwrap_or_default();
2835        if !entry_url.is_empty() {
2836            if starred {
2837                let mut saved = Saved::new(entry_url.clone(), now_rfc3339());
2838                saved.title = entry.title.clone();
2839                saved.feed_url = feed_url_for_id(pool, entry.feed_id).await;
2840                saved.entry_id = Some(entry.guid.clone());
2841                match state.repo().add_saved(&did, &saved).await {
2842                    Ok(rkey) => info!(%did, url = %entry_url, %rkey, "wrote saved record to PDS"),
2843                    Err(err) => warn!(%err, %did, "PDS saved write failed (starred locally)"),
2844                }
2845            } else {
2846                // Un-star: find and delete the matching saved record by URL.
2847                match state.repo().list_saved(&did).await {
2848                    Ok(records) => {
2849                        for (rkey, _rec) in records.iter().filter(|(_, r)| r.url == entry_url) {
2850                            if let Err(err) = state.repo().remove_saved(&did, rkey).await {
2851                                warn!(%err, %did, %rkey, "PDS saved delete failed");
2852                            }
2853                        }
2854                    }
2855                    Err(err) => warn!(%err, %did, "could not list saved records to un-star"),
2856                }
2857            }
2858        }
2859    }
2860
2861    if !is_htmx(&headers) {
2862        return Ok(Redirect::to("/").into_response());
2863    }
2864
2865    // Reader → out-of-band action-bar fragment; list → the row (see mark_read).
2866    if is_reader_request(&headers) {
2867        let read = entry_is_read(pool, &did, id).await?;
2868        return Ok(render(&EntryActionBarTemplate {
2869            id,
2870            read,
2871            starred,
2872            oob: true,
2873        }));
2874    }
2875
2876    let row = build_entry_row(pool, &did, id, None).await?;
2877    match row {
2878        Some(r) => Ok(render(&EntryRowTemplate { e: r })),
2879        None => Ok((StatusCode::NOT_FOUND, "entry not found").into_response()),
2880    }
2881}
2882
2883/// The feed URL for a cached feed id, if the row exists.
2884async fn feed_url_for_id(pool: &store::Pool, feed_id: i64) -> Option<String> {
2885    sqlx::query_scalar::<_, String>("SELECT url FROM feeds WHERE id = ?1")
2886        .bind(feed_id)
2887        .fetch_optional(pool)
2888        .await
2889        .ok()
2890        .flatten()
2891}
2892
2893// ---------------------------------------------------------------------------
2894// Mark-all-read
2895// ---------------------------------------------------------------------------
2896
2897/// Query for `POST /read-all` — an optional `?feed=<url>` scopes it to one feed;
2898/// absent means mark everything read.
2899#[derive(Debug, Deserialize, Default)]
2900struct ReadAllQuery {
2901    #[serde(default)]
2902    feed: Option<String>,
2903}
2904
2905/// `POST /read-all` — mark every entry read for the current DID, optionally
2906/// scoped to one feed (mark-all-read per feed or globally).
2907async fn mark_all_read(
2908    State(state): State<AppState>,
2909    headers: HeaderMap,
2910    Query(q): Query<ReadAllQuery>,
2911) -> Result<Response, WebError> {
2912    let did = match current_did(&state, &headers).await {
2913        Some(d) => d,
2914        None => return Ok(Redirect::to("/login").into_response()),
2915    };
2916    let pool = &state.db;
2917
2918    // Refresh the caller's `sub_ref` projection so the scoped mark-read writes
2919    // only ever touch feeds this DID actually subscribes to.
2920    resolve_subscriptions(&state, &did).await;
2921
2922    if let Some(feed_url) = q.feed.as_deref() {
2923        if let Ok(Some(feed)) = store::get_feed_by_url(pool, feed_url).await {
2924            store::mark_feed_read(pool, &did, feed.id, true).await?;
2925        }
2926        return Ok(Redirect::to(&format!("/?feed={}", qenc(feed_url))).into_response());
2927    }
2928
2929    // Global: mark every subscribed feed read. Fan out over the DID's feeds
2930    // (bounded by the per-DID subscription cap) using the batched per-feed path,
2931    // rather than one UPDATE round-trip per unread entry (unbounded) — same end
2932    // state, but O(feeds) statements instead of O(unread entries).
2933    for feed_id in store::subscribed_feed_ids(pool, &did).await? {
2934        store::mark_feed_read(pool, &did, feed_id, true).await?;
2935    }
2936    Ok(Redirect::to("/").into_response())
2937}
2938
2939// ---------------------------------------------------------------------------
2940// Subscribe by URL
2941// ---------------------------------------------------------------------------
2942
2943/// Flash for a URL this instance cannot store as a feed — not private, just
2944/// not a kind of feed it supports (an `at://` publication with
2945/// `FEATHERREADER_STANDARD_SITE` off, an unsupported scheme). Distinct from
2946/// [`PRIVATE_FEED_REFUSAL`], whose "not saved or sent anywhere" would be a
2947/// false promise for a record that may already exist in the user's PDS.
2948const UNSUPPORTED_FEED_URL_REFUSAL: &str =
2949    "That isn't a kind of feed this instance can subscribe to. Nothing was saved.";
2950
2951/// Refusal message shown when a private/paid feed is submitted. FeatherReader
2952/// stores subscriptions in the user's PUBLIC PDS, so it supports public feeds
2953/// only for now — a private feed's secret URL is never saved, fetched, or sent
2954/// anywhere. Kept as a constant so the add and OPML paths share the exact wording
2955/// and the boot-smoke can assert on it.
2956const PRIVATE_FEED_REFUSAL: &str = "Private/paid feeds aren't supported yet. \
2957    FeatherReader stores your subscriptions in your public PDS, so it supports public \
2958    feeds for now — private-feed support arrives when atproto's private data \
2959    (permissioned records) ships. Your feed URL was not saved or sent anywhere.";
2960
2961/// Form body for `POST /subscriptions`.
2962#[derive(Debug, Deserialize)]
2963struct SubscribeForm {
2964    url: String,
2965    /// Optional folder `at://` URI to file the new feed under.
2966    #[serde(default)]
2967    folder: Option<String>,
2968}
2969
2970/// `POST /subscriptions` — subscribe by URL.
2971async fn add_subscription(
2972    State(state): State<AppState>,
2973    headers: HeaderMap,
2974    Form(form): Form<SubscribeForm>,
2975) -> Result<Response, WebError> {
2976    let did = match current_did(&state, &headers).await {
2977        Some(d) => d,
2978        None => return Ok(Redirect::to("/login").into_response()),
2979    };
2980    let pool = &state.db;
2981    let input = form.url.trim().to_string();
2982    if input.is_empty() {
2983        return Ok(Redirect::to("/").into_response());
2984    }
2985
2986    // An `at://` paste is refused here, whatever the flag says: this path must
2987    // FETCH what was pasted to find the feed in it, and nothing fetches
2988    // `at://` until the standard.site reader is wired. Letting a well-formed
2989    // one through produced "Couldn't find a feed" and a `warn!` for an
2990    // expected condition; letting a malformed one reach the privacy arm, which
2991    // fails closed as `Private`, told the reader a typo was a paid feed. Only
2992    // `at://` is pre-checked — an http(s) or scheme-less paste keeps its
2993    // "Couldn't find a feed" path below, which is the accurate answer there.
2994    // Case-insensitive, unlike the storage guards: `Url::parse` folds the
2995    // scheme, so `AT://…` would otherwise skip both this and the classifier's
2996    // at:// arm, parse as `at`, and draw the private/paid flash off the rkey.
2997    // This decides a MESSAGE; nothing about storage keys off it.
2998    if input
2999        .get(..crate::atproto::AT_URI_PREFIX.len())
3000        .is_some_and(|p| p.eq_ignore_ascii_case(crate::atproto::AT_URI_PREFIX))
3001    {
3002        info!(url = %input, %did, "refused an at:// paste: the add path cannot fetch one (not stored)");
3003        return Ok(
3004            Redirect::to(&format!("/?flash={}", qenc(UNSUPPORTED_FEED_URL_REFUSAL)))
3005                .into_response(),
3006        );
3007    }
3008
3009    // Block private/paid feeds BEFORE any fetch/resolve so a secret-bearing URL is
3010    // never even requested. Public feeds only until atproto permissioned data
3011    // ships; there is no override and nothing is stored or written.
3012    if let feed::FeedPrivacy::Private(reason) = feed::classify_feed_privacy(&input) {
3013        info!(url = %input, %reason, %did, "refused private/paid feed at add (not fetched or stored)");
3014        return Ok(
3015            Redirect::to(&format!("/?flash={}", qenc(PRIVATE_FEED_REFUSAL))).into_response(),
3016        );
3017    }
3018
3019    // Per-DID subscription cap: bound one account's storage/poller footprint on
3020    // the small box. Checked BEFORE any fetch/resolve so an over-cap account
3021    // can't even trigger an outbound request. `<= 0` disables the cap.
3022    let cap = state.config.max_subs_per_did;
3023    if cap > 0 {
3024        match store::count_subscriptions_for_did(pool, &did).await {
3025            Ok(n) if n >= cap => {
3026                info!(%did, current = n, cap, "refused subscribe: per-DID subscription cap reached");
3027                return Ok(Redirect::to(&format!(
3028                    "/?flash={}",
3029                    qenc(&format!(
3030                        "Subscription limit reached ({cap}). Remove a feed before adding another."
3031                    ))
3032                ))
3033                .into_response());
3034            }
3035            Ok(_) => {}
3036            Err(err) => warn!(%err, %did, "could not count subscriptions for cap check; allowing"),
3037        }
3038    }
3039
3040    let feed_url = match resolve_feed_url(&state.config, &input).await {
3041        Ok(u) => u,
3042        Err(err) => {
3043            warn!(%err, url = %input, "could not resolve a feed from the given URL");
3044            return Ok(Redirect::to(&format!(
3045                "/?flash={}",
3046                qenc("Couldn't find a feed at that URL")
3047            ))
3048            .into_response());
3049        }
3050    };
3051
3052    // Defensive: resolution may have discovered a feed URL that itself carries a
3053    // secret (e.g. a public site page linking a tokened feed). Re-check the
3054    // resolved URL and refuse before storing/writing anything.
3055    if let feed::FeedPrivacy::Private(reason) = feed::classify_feed_privacy(&feed_url) {
3056        info!(url = %feed_url, %reason, %did, "refused private/paid feed after resolution (not stored)");
3057        return Ok(
3058            Redirect::to(&format!("/?flash={}", qenc(PRIVATE_FEED_REFUSAL))).into_response(),
3059        );
3060    }
3061
3062    // The URL about to be STORED is what must be storable — not the one the
3063    // user typed. Autodiscovery already yields only http(s), but this is the
3064    // path that writes the row and the PDS record, so the check lives here too:
3065    // the same gate the OPML and rename paths apply, on the same terms.
3066    if !feed::is_storable_feed_url(&feed_url, state.config.standard_site) {
3067        info!(url = %feed_url, %did, "refused unsupported feed URL after resolution (not stored)");
3068        return Ok(
3069            Redirect::to(&format!("/?flash={}", qenc(UNSUPPORTED_FEED_URL_REFUSAL)))
3070                .into_response(),
3071        );
3072    }
3073
3074    // Global feeds ceiling: a brand-new distinct feed is refused once the shared
3075    // cache is full (an existing/duplicate feed URL is always fine — it adds no
3076    // row). Bounds total cache size across all users on the box. `<= 0` disables.
3077    let feeds_cap = state.config.max_feeds_global;
3078    if feeds_cap > 0 && store::get_feed_by_url(pool, &feed_url).await?.is_none() {
3079        match store::count_feeds(pool).await {
3080            Ok(n) if n >= feeds_cap => {
3081                warn!(%did, feeds = n, cap = feeds_cap, feed = %feed_url, "refused subscribe: global feeds ceiling reached");
3082                return Ok(Redirect::to(&format!(
3083                    "/?flash={}",
3084                    qenc(
3085                        "This instance is at its feed capacity right now. Please try again later."
3086                    )
3087                ))
3088                .into_response());
3089            }
3090            Ok(_) => {}
3091            Err(err) => warn!(%err, "could not count feeds for global-cap check; allowing"),
3092        }
3093    }
3094
3095    store::upsert_feed(
3096        pool,
3097        &store::NewFeed {
3098            url: feed_url.clone(),
3099            ..Default::default()
3100        },
3101    )
3102    .await?;
3103
3104    if let Ok(client) = feed::build_client() {
3105        if let Some(feed_row) = store::get_feed_by_url(pool, &feed_url).await? {
3106            match feed::poll_feed(pool, &client, &feed_row, state.config.max_entries_per_feed).await
3107            {
3108                Ok(outcome) => {
3109                    info!(feed = %feed_url, ?outcome, "polled new subscription");
3110                    // **This path is not the scheduler, so it must settle the
3111                    // error columns itself.** `poll_feed` writes validators and
3112                    // `last_polled` and nothing else.
3113                    feed::settle_poll(pool, &feed_url, &outcome, state.config.poll_interval).await;
3114                }
3115                Err(err) => warn!(%err, feed = %feed_url, "initial poll failed"),
3116            }
3117        }
3118    }
3119
3120    let mut sub = Subscription::new(feed_url.clone(), now_rfc3339());
3121    if let Ok(Some(feed_row)) = store::get_feed_by_url(pool, &feed_url).await {
3122        sub.title = feed_row.title.clone();
3123        sub.site_url = feed_row.site_url.clone();
3124    }
3125    sub.folder = form
3126        .folder
3127        .map(|f| f.trim().to_string())
3128        .filter(|f| !f.is_empty());
3129
3130    match state.repo().add_subscription(&did, &sub).await {
3131        Ok(rkey) => info!(feed = %feed_url, %rkey, %did, "wrote subscription record to PDS"),
3132        Err(err) => {
3133            warn!(%err, feed = %feed_url, %did, "PDS subscription write failed (cached locally)")
3134        }
3135    }
3136
3137    Ok(Redirect::to("/").into_response())
3138}
3139
3140/// `POST /subscriptions/:rkey/delete` — unsubscribe (delete the PDS record).
3141async fn delete_subscription(
3142    State(state): State<AppState>,
3143    headers: HeaderMap,
3144    Path(rkey): Path<String>,
3145) -> Result<Response, WebError> {
3146    let did = match current_did(&state, &headers).await {
3147        Some(d) => d,
3148        None => return Ok(Redirect::to("/login").into_response()),
3149    };
3150    match state.repo().remove_subscription(&did, &rkey).await {
3151        Ok(()) => info!(%did, %rkey, "unsubscribed (deleted PDS subscription record)"),
3152        Err(err) => warn!(%err, %did, %rkey, "PDS unsubscribe failed"),
3153    }
3154    Ok(Redirect::to("/").into_response())
3155}
3156
3157/// Form body for `POST /subscriptions/:rkey/rename`.
3158#[derive(Debug, Deserialize)]
3159struct RenameSubForm {
3160    url: String,
3161    #[serde(default)]
3162    title: Option<String>,
3163    #[serde(default)]
3164    site_url: Option<String>,
3165    #[serde(default)]
3166    folder: Option<String>,
3167}
3168
3169/// `POST /subscriptions/:rkey/rename` — retitle a feed and/or move it to a
3170/// folder, rewriting the whole subscription record via `putRecord`.
3171async fn rename_subscription(
3172    State(state): State<AppState>,
3173    headers: HeaderMap,
3174    Path(rkey): Path<String>,
3175    Form(form): Form<RenameSubForm>,
3176) -> Result<Response, WebError> {
3177    let did = match current_did(&state, &headers).await {
3178        Some(d) => d,
3179        None => return Ok(Redirect::to("/login").into_response()),
3180    };
3181    let feed_url = form.url.trim().to_string();
3182
3183    // Reject an empty/blank resolved URL — a rename with no usable URL must not
3184    // write a junk row to the cache or a malformed subscription record to the
3185    // PDS (add_subscription refuses an empty input the same way).
3186    if feed_url.is_empty() {
3187        return Ok(Redirect::to("/").into_response());
3188    }
3189
3190    // **Read before write — `update_subscription` is a `putRecord`, and a
3191    // putRecord replaces the WHOLE record** (see its doc on `atproto.rs`).
3192    //
3193    // This used to build a fresh `Subscription::new(feed_url, now_rfc3339())`
3194    // and hand that over, so every field the form does not carry was written
3195    // back as its default. `templates/manage_row.html` posts `url`, `title` and
3196    // `folder` — and nothing else — so a rename silently destroyed four fields:
3197    // `siteUrl`, `fetchHint`, `private`, and `createdAt`.
3198    //
3199    // `createdAt` is the one that matters most: it is the reader's subscribe
3200    // time, it is the sort key for "when did I subscribe", it lives in THEIR
3201    // repo rather than our cache, and once overwritten it is gone with nothing
3202    // in the UI to say so.
3203    //
3204    // There is no single-record read on `Repo` (no `getRecord`), so this lists
3205    // and filters. That is one extra round trip on an action that is already
3206    // doing a PDS write, and it is bounded; a `get_subscription` would be
3207    // strictly better if this ever measures badly.
3208    //
3209    // **A failed read refuses the rename.** Falling back to the old
3210    // rebuild-from-scratch here would reinstate the data loss on exactly the
3211    // flaky path, which is the worst place to have it. The write below already
3212    // takes this stance — "a failure here means nothing was renamed or moved" —
3213    // and the read gets the same one.
3214    let existing = match state.repo().list_subscriptions_sorted(&did).await {
3215        Ok(subs) => subs.into_iter().find(|(k, _)| *k == rkey).map(|(_, s)| s),
3216        Err(err) => {
3217            warn!(%err, %did, %rkey, "could not read the subscription before renaming it");
3218            return Ok(Redirect::to(&format!(
3219                "/?flash={}",
3220                qenc("Could not reach your PDS — nothing was renamed or moved.")
3221            ))
3222            .into_response());
3223        }
3224    };
3225    let Some(existing) = existing else {
3226        // The rkey is not in the reader's repo. Renaming a record that is not
3227        // there would CREATE one, which is not what "rename" means and would
3228        // give it a fresh `createdAt` — the bug this read exists to prevent.
3229        warn!(%did, %rkey, "refused rename: no such subscription in the repo");
3230        return Ok(Redirect::to(&format!(
3231            "/?flash={}",
3232            qenc("That subscription is no longer in your repo — nothing was renamed or moved.")
3233        ))
3234        .into_response());
3235    };
3236
3237    // The subscription can be repointed at a different feed URL. **Every gate
3238    // on the URL applies to a repoint and only a repoint** — the three below
3239    // were each, at one time, run before this line on the URL as posted, and
3240    // each refused a pure retitle of a record that already existed:
3241    //
3242    // - privacy: the narrowed at:// arm fails closed as `Private` for an
3243    //   at-URI that is not a publication (a feed generator another client
3244    //   subscribed to), so the record became un-editable with a flash saying
3245    //   it "was not saved or sent anywhere";
3246    // - the global feeds ceiling keyed on "URL not in the cache", and an
3247    //   at:// record is never cached with the flag off, so at capacity a
3248    //   retitle was refused for a row the handler would not insert;
3249    // - storability, the same way.
3250    //
3251    // An unchanged URL is already in the reader's repo; refusing to retitle
3252    // it protects nothing and takes their own record away from them.
3253    // Like for like: the form value is trimmed, and a record another client
3254    // wrote may carry padding — compared raw, every retitle of it was a repoint.
3255    let url_changed = existing.url.trim() != feed_url;
3256
3257    // **Storability, on the same terms as the add and OPML paths — for a
3258    // REPOINT, and FIRST.** A target this instance cannot store gets that
3259    // answer, not "private" (the at:// arm fails closed) or "at capacity"
3260    // (it would never be inserted) — the ordering the add path has. This handler writes `feeds` via `upsert_feed` and had only the
3261    // privacy check above, so `FEATHERREADER_STANDARD_SITE` was bypassable
3262    // here; a review found it by enumerating every writer of the table. The
3263    // first fix ran this check before the repo lookup, on the URL as posted —
3264    // which refused a pure retitle of a subscription that already IS an
3265    // at-URI, on every instance with the flag off. The flag gates what the
3266    // cache may store, not whether a reader may edit their own record: an
3267    // unchanged non-storable URL keeps its PDS write and simply gets no cache
3268    // row below.
3269    let storable = feed::is_storable_feed_url(&feed_url, state.config.standard_site);
3270    if url_changed && !storable {
3271        info!(url = %feed_url, %did, %rkey, "refused a repoint to a non-storable feed URL");
3272        return Ok(
3273            Redirect::to(&format!("/?flash={}", qenc(UNSUPPORTED_FEED_URL_REFUSAL)))
3274                .into_response(),
3275        );
3276    }
3277
3278    // Block private/paid feeds on a repoint. `url` is attacker-controllable,
3279    // and rename both upserts it to the local cache AND rewrites the PDS
3280    // subscription record (a public `putRecord`), so without this guard a
3281    // crafted rename could land a secret-bearing URL in the public PDS — the
3282    // exact leak the add and OPML paths already prevent.
3283    if url_changed {
3284        if let feed::FeedPrivacy::Private(reason) = feed::classify_feed_privacy(&feed_url) {
3285            info!(url = %feed_url, %reason, %did, %rkey, "refused private/paid feed at rename (not stored or written)");
3286            return Ok(
3287                Redirect::to(&format!("/?flash={}", qenc(PRIVATE_FEED_REFUSAL))).into_response(),
3288            );
3289        }
3290    }
3291
3292    // Global feeds ceiling parity with add_subscription: a repoint to a
3293    // brand-new feed URL would insert a NEW `feeds` row. Refuse that when the
3294    // shared cache is at capacity (an existing/duplicate URL adds no row and
3295    // is always fine). `<= 0` disables.
3296    let feeds_cap = state.config.max_feeds_global;
3297    if url_changed
3298        && feeds_cap > 0
3299        && store::get_feed_by_url(&state.db, &feed_url)
3300            .await?
3301            .is_none()
3302    {
3303        match store::count_feeds(&state.db).await {
3304            Ok(n) if n >= feeds_cap => {
3305                warn!(%did, %rkey, feeds = n, cap = feeds_cap, feed = %feed_url, "refused rename: global feeds ceiling reached");
3306                return Ok(Redirect::to(&format!(
3307                    "/?flash={}",
3308                    qenc(
3309                        "This instance is at its feed capacity right now. Please try again later."
3310                    )
3311                ))
3312                .into_response());
3313            }
3314            Ok(_) => {}
3315            Err(err) => warn!(%err, "could not count feeds for global-cap check; allowing"),
3316        }
3317    }
3318
3319    let mut sub = existing;
3320    sub.url = feed_url;
3321    sub.title = form
3322        .title
3323        .map(|t| t.trim().to_string())
3324        .filter(|t| !t.is_empty());
3325    sub.folder = form
3326        .folder
3327        .map(|f| f.trim().to_string())
3328        .filter(|f| !f.is_empty());
3329    // `createdAt` and `private` carry over untouched — neither is a property of
3330    // which feed URL the subscription points at.
3331    //
3332    // `siteUrl` and `fetchHint` ARE properties of the specific feed, so a
3333    // repoint drops them rather than leaving a site link for the old feed
3334    // hanging off the new one. An explicit form value still wins if the form
3335    // ever starts carrying one.
3336    match form
3337        .site_url
3338        .map(|t| t.trim().to_string())
3339        .filter(|t| !t.is_empty())
3340    {
3341        Some(site) => sub.site_url = Some(site),
3342        None if url_changed => sub.site_url = None,
3343        None => {}
3344    }
3345    if url_changed {
3346        sub.fetch_hint = None;
3347    }
3348
3349    // Keep the local cache title in step for the loose-feed fallback path —
3350    // for a row this instance would have. Two cases write nothing:
3351    //
3352    // - not storable (an existing at-URI with the flag off): the record is the
3353    //   reader's to edit, the cache row is not this instance's to create;
3354    // - an unchanged URL with no cache row: a retitle is never the write that
3355    //   CREATES a row. That covers two findings at once — the ceiling is
3356    //   checked on a repoint only, so a retitle must not insert past it; and
3357    //   a secret-bearing URL another client subscribed to has no row (the
3358    //   privacy gate above runs on a repoint only, and `resolve_subscriptions`
3359    //   refuses to cache it), so it cannot enter the shared table here, be
3360    //   polled, fail, and be printed on the admin page. A privacy re-check on
3361    //   this write was the first draft; mutation showed it dead — the row
3362    //   rule already refused every case it would have.
3363    let cache_write =
3364        storable && (url_changed || store::get_feed_by_url(&state.db, &sub.url).await?.is_some());
3365    if !cache_write {
3366        info!(%did, %rkey, url = %sub.url, "renamed a subscription without touching the cache");
3367    } else if let Err(err) = store::upsert_feed(
3368        &state.db,
3369        &store::NewFeed {
3370            url: sub.url.clone(),
3371            title: sub.title.clone(),
3372            site_url: sub.site_url.clone(),
3373            ..Default::default()
3374        },
3375    )
3376    .await
3377    {
3378        // Not fatal to the rename — the PDS record below is the source of truth
3379        // — but a missing `feeds` row means this subscription is never polled.
3380        warn!(%err, %did, url = %sub.url, "could not update the cached feed row on rename");
3381    }
3382
3383    // **The PDS write decides what the reader is told.**
3384    //
3385    // This used to `warn!` on failure and then redirect exactly as it does on
3386    // success, so a rename that did not happen was indistinguishable from one
3387    // that did — the reader saw their old title come back and had no reason to
3388    // think anything had gone wrong. The PDS record IS the subscription; a
3389    // failure here means nothing was renamed or moved.
3390    match state.repo().update_subscription(&did, &rkey, &sub).await {
3391        Ok(res) => {
3392            info!(%did, %rkey, uri = %res.uri, "renamed/moved subscription");
3393            Ok(Redirect::to("/").into_response())
3394        }
3395        Err(err) => {
3396            warn!(%err, %did, %rkey, "PDS subscription update failed");
3397            Ok(Redirect::to(&format!(
3398                "/?flash={}",
3399                qenc("Could not save that change to your PDS — nothing was renamed or moved.")
3400            ))
3401            .into_response())
3402        }
3403    }
3404}
3405
3406// ---------------------------------------------------------------------------
3407// Folders
3408// ---------------------------------------------------------------------------
3409
3410/// Form body for `POST /folders`.
3411#[derive(Debug, Deserialize)]
3412struct FolderForm {
3413    name: String,
3414}
3415
3416/// `POST /folders` — create a folder record.
3417async fn create_folder(
3418    State(state): State<AppState>,
3419    headers: HeaderMap,
3420    Form(form): Form<FolderForm>,
3421) -> Result<Response, WebError> {
3422    let did = match current_did(&state, &headers).await {
3423        Some(d) => d,
3424        None => return Ok(Redirect::to("/login").into_response()),
3425    };
3426    let name = form.name.trim();
3427    if name.is_empty() {
3428        return Ok(Redirect::to("/").into_response());
3429    }
3430    let folder = Folder::new(name.to_string(), now_rfc3339());
3431    match state.repo().add_folder(&did, &folder).await {
3432        Ok(rkey) => info!(%did, %rkey, name, "created folder record"),
3433        Err(err) => warn!(%err, %did, "PDS folder create failed"),
3434    }
3435    Ok(Redirect::to("/").into_response())
3436}
3437
3438/// `POST /folders/:rkey/rename` — rename a folder record.
3439async fn rename_folder(
3440    State(state): State<AppState>,
3441    headers: HeaderMap,
3442    Path(rkey): Path<String>,
3443    Form(form): Form<FolderForm>,
3444) -> Result<Response, WebError> {
3445    let did = match current_did(&state, &headers).await {
3446        Some(d) => d,
3447        None => return Ok(Redirect::to("/login").into_response()),
3448    };
3449    let name = form.name.trim();
3450    if name.is_empty() {
3451        return Ok(Redirect::to("/").into_response());
3452    }
3453    let folder = Folder::new(name.to_string(), now_rfc3339());
3454    match state.repo().rename_folder(&did, &rkey, &folder).await {
3455        Ok(res) => info!(%did, %rkey, uri = %res.uri, "renamed folder"),
3456        Err(err) => warn!(%err, %did, %rkey, "PDS folder rename failed"),
3457    }
3458    Ok(Redirect::to("/").into_response())
3459}
3460
3461/// `POST /folders/:rkey/delete` — delete a folder record (feeds referencing it
3462/// simply become un-foldered).
3463async fn delete_folder(
3464    State(state): State<AppState>,
3465    headers: HeaderMap,
3466    Path(rkey): Path<String>,
3467) -> Result<Response, WebError> {
3468    let did = match current_did(&state, &headers).await {
3469        Some(d) => d,
3470        None => return Ok(Redirect::to("/login").into_response()),
3471    };
3472    match state.repo().remove_folder(&did, &rkey).await {
3473        Ok(()) => info!(%did, %rkey, "deleted folder record"),
3474        Err(err) => warn!(%err, %did, %rkey, "PDS folder delete failed"),
3475    }
3476    Ok(Redirect::to("/").into_response())
3477}
3478
3479/// Resolve a user-pasted URL to a canonical feed URL: if fetching it yields a
3480/// feed document we take it as-is; if it yields an HTML page we run
3481/// autodiscovery over its `<link rel="alternate">` tags.
3482async fn resolve_feed_url(_config: &Config, input: &str) -> anyhow::Result<String> {
3483    let parsed =
3484        url::Url::parse(input).map_err(|e| anyhow::anyhow!("not a valid URL {input:?}: {e}"))?;
3485
3486    let client = feed::build_client()?;
3487    // Fetch through the SSRF guard: scheme + resolved-IP checks on the URL and
3488    // every redirect hop, so a user-pasted URL can't reach cloud metadata /
3489    // loopback / private hosts.
3490    let resp = crate::net::guarded_get(&client, parsed.as_str(), &[]).await?;
3491    let final_url = resp.url().clone();
3492    let content_type = resp
3493        .headers()
3494        .get(axum::http::header::CONTENT_TYPE)
3495        .and_then(|v| v.to_str().ok())
3496        .unwrap_or("")
3497        .to_ascii_lowercase();
3498    // Cap the body (streamed, aborts over 8 MiB) — never trust Content-Length,
3499    // gzip strips it, and this response is reflected into the UI.
3500    let raw = crate::net::read_capped(resp).await?;
3501    let body = String::from_utf8_lossy(&raw).into_owned();
3502
3503    let looks_like_feed = content_type.contains("xml")
3504        || content_type.contains("rss")
3505        || content_type.contains("atom")
3506        || content_type.contains("application/feed+json")
3507        || {
3508            let head = body.trim_start();
3509            head.starts_with("<?xml")
3510                || head.starts_with("<rss")
3511                || head.starts_with("<feed")
3512                || head.contains("<rss")
3513                || head.contains("<feed")
3514        };
3515    if looks_like_feed {
3516        return Ok(final_url.to_string());
3517    }
3518
3519    match feed::discover_feed(&body, Some(&final_url)) {
3520        Some(u) => Ok(u.to_string()),
3521        None => anyhow::bail!("no feed found at {input} (no autodiscovery link)"),
3522    }
3523}
3524
3525// ---------------------------------------------------------------------------
3526// Login (atproto OAuth via the sidecar)
3527// ---------------------------------------------------------------------------
3528
3529/// Query for `GET /login`.
3530#[derive(Debug, Deserialize, Default)]
3531struct LoginQuery {
3532    #[serde(default)]
3533    handle: Option<String>,
3534    #[serde(default)]
3535    error: Option<String>,
3536    #[serde(default)]
3537    flash: Option<String>,
3538}
3539
3540/// `GET /login` — start the atproto OAuth flow, or render the handle form.
3541///
3542/// **Pre-handshake gate:** starting OAuth (a `?handle=` GET) is refused unless
3543/// the visitor is allowed by [`may_start_oauth`] — an existing beta seat (via
3544/// session cookie *or* the submitted handle resolving to a seated DID) or a
3545/// valid reserving invite cookie. Refusal redirects to `/beta/redeem`. The bare
3546/// form (no handle) always renders.
3547async fn login_form(
3548    State(state): State<AppState>,
3549    headers: HeaderMap,
3550    Query(q): Query<LoginQuery>,
3551) -> Response {
3552    if let Some(handle) = q
3553        .handle
3554        .map(|h| h.trim().to_string())
3555        .filter(|h| !h.is_empty())
3556    {
3557        if !may_start_oauth(&state, &headers, &handle).await {
3558            return Redirect::to("/beta/redeem").into_response();
3559        }
3560        return start_oauth(&state, &handle).await;
3561    }
3562    render(&LoginTemplate {
3563        repo_url: REPO_URL,
3564        error: q.error.unwrap_or_default(),
3565        flash: q.flash.unwrap_or_default(),
3566    })
3567}
3568
3569/// `POST /login` — the handle-form submit: redirect into the sidecar OAuth flow.
3570/// Subject to the same pre-handshake invite gate as `GET /login?handle=`.
3571async fn login_submit(
3572    State(state): State<AppState>,
3573    headers: HeaderMap,
3574    Form(form): Form<LoginForm>,
3575) -> Response {
3576    let handle = form.handle.trim();
3577    if handle.is_empty() {
3578        return login_error("Enter your atproto handle.");
3579    }
3580    if !may_start_oauth(&state, &headers, handle).await {
3581        return Redirect::to("/beta/redeem").into_response();
3582    }
3583    start_oauth(&state, handle).await
3584}
3585
3586/// Whether this visitor is allowed to *start* the OAuth handshake. The gate
3587/// admits, in order of cost:
3588///
3589/// 1. an existing beta member's cookie session whose DID already holds a seat;
3590/// 2. a fresh visitor carrying a valid reserving invite cookie;
3591/// 3. a cookie-less visitor whose submitted `handle` resolves to a DID that
3592///    already holds a seat — this honors the **seeded admin's first login** on a
3593///    fresh deploy (and any returning member who cleared cookies) without a
3594///    session cookie or an invite code.
3595///
3596/// The cookie/invite fast paths run FIRST and short-circuit, so the network
3597/// handle→DID resolution is only attempted when neither applies. It fails
3598/// CLOSED: a malformed/unresolvable handle, a resolution error/timeout, or a
3599/// resolved DID with no seat all leave the visitor bounced to `/beta/redeem`.
3600/// This keeps the anti-abuse intent — a rando now pays a cheap handle
3601/// resolution instead of a burned sidecar handshake (and `/login` is already in
3602/// the rate-limited path set).
3603async fn may_start_oauth(state: &AppState, headers: &HeaderMap, handle: &str) -> bool {
3604    // The production resolver is the app's existing atproto handle→DID path,
3605    // routed through the SSRF guard. Resolution is injected so tests can exercise
3606    // the gate without a live network call (the guard forbids loopback mocks).
3607    may_start_oauth_with(state, headers, handle, |h| async move {
3608        crate::atproto::resolve_handle(&state.http, &state.config.resolver_base, &h)
3609            .await
3610            .ok()
3611    })
3612    .await
3613}
3614
3615/// Core of [`may_start_oauth`] with the handle→DID resolver injected as `resolve`
3616/// (returning `Some(did)` on success, `None` on any failure/unresolvable handle).
3617/// The cookie + invite fast paths run FIRST and short-circuit, so `resolve` is
3618/// only called when neither admits — keeping the network round-trip off the hot
3619/// path and preserving the fail-closed contract on resolution failure.
3620async fn may_start_oauth_with<F, Fut>(
3621    state: &AppState,
3622    headers: &HeaderMap,
3623    handle: &str,
3624    resolve: F,
3625) -> bool
3626where
3627    F: FnOnce(String) -> Fut,
3628    Fut: std::future::Future<Output = Option<String>>,
3629{
3630    // 1. An already-beta'd session may re-auth freely.
3631    if let Some(did) = current_did(state, headers).await {
3632        if store::has_beta_access(&state.db, &did)
3633            .await
3634            .unwrap_or(false)
3635        {
3636            return true;
3637        }
3638    }
3639    // 2. A valid reserving invite cookie.
3640    if invite_cookie_code(headers, &state.config.cookie_secret).is_some() {
3641        return true;
3642    }
3643    // 3. Cookie-less: honor an existing seat by resolving the submitted handle to
3644    //    a DID (the seeded-admin first-login / cleared-cookies case). Fail closed
3645    //    on any resolution error or unresolvable/malformed handle.
3646    match resolve(handle.to_string()).await {
3647        Some(did) => store::has_beta_access(&state.db, &did)
3648            .await
3649            .unwrap_or(false),
3650        None => {
3651            warn!(%handle, "handle resolution failed in pre-handshake beta gate");
3652            false
3653        }
3654    }
3655}
3656
3657/// Begin the OAuth handshake for `handle`, on whichever backend is live.
3658///
3659/// **On `form-action 'self'` and this redirect.** The Rust arm answers a form
3660/// POST with a redirect straight to the PDS — cross-origin — while the app's CSP
3661/// carries `form-action 'self'`. Browsers have historically disagreed about
3662/// whether that directive applies to redirects following a form submission, and
3663/// if it did here, login would break in a browser while every test passed.
3664///
3665/// It does not, and the evidence is the SIDECAR path, which is live in
3666/// production today: `POST /login` -> 303 to the same-origin `/oauth/login` ->
3667/// 302 to the PDS, cross-origin, under this same CSP. A browser checking the
3668/// whole redirect chain would already be blocking that. One checking only the
3669/// form's action URL sees `/login` in both cases. The two arms differ only in
3670/// how many same-origin hops precede the cross-origin one, so any policy that
3671/// permits the sidecar flow permits this one.
3672///
3673/// The two arms differ in SHAPE, not just in implementation. The sidecar owns
3674/// its own `/login` and its own callback, so starting a login is one redirect
3675/// and nothing is stored here. The Rust backend pushes the authorization
3676/// request itself, which means this app now holds the pending login — and must
3677/// set the browser-binding cookie that the callback will be checked against.
3678async fn start_oauth(state: &AppState, handle: &str) -> Response {
3679    match state.config.repo_backend {
3680        crate::metrics::Backend::Sidecar => {
3681            let url = state.sidecar.login_url(handle, None);
3682            info!(%handle, "redirecting to OAuth sidecar login");
3683            Redirect::to(&url).into_response()
3684        }
3685        crate::metrics::Backend::Rust => {
3686            let Some(runtime) = state.oauth.as_deref() else {
3687                warn!("the rust backend is live but its OAuth runtime is absent");
3688                return login_error("Login is not available right now.");
3689            };
3690            match crate::oauth::login::start(
3691                runtime,
3692                &state.http,
3693                &state.db,
3694                handle,
3695                crate::store::now_unix(),
3696            )
3697            .await
3698            {
3699                Ok(started) => {
3700                    info!(%handle, "pushed authorization request; redirecting to the PDS");
3701                    let mut resp = Redirect::to(&started.authorize_url).into_response();
3702                    set_cookie(
3703                        &mut resp,
3704                        &cookie::sign_value(
3705                            OAUTH_BINDING_COOKIE,
3706                            &started.binding_token,
3707                            &state.config.cookie_secret,
3708                            OAUTH_BINDING_MAX_AGE_SECS,
3709                        ),
3710                    );
3711                    resp
3712                }
3713                Err(err) => {
3714                    // The handle the user typed is logged; the error is not shown
3715                    // to them verbatim, since it can name internal hosts.
3716                    warn!(%err, %handle, "could not start the OAuth login");
3717                    login_error("Could not start login for that handle.")
3718                }
3719            }
3720        }
3721    }
3722}
3723
3724/// Clear the browser-binding cookie. Called on every terminal outcome of a
3725/// callback, successful or not: the pending row is consumed either way, so a
3726/// lingering cookie can only ever match a login that no longer exists.
3727fn clear_binding_cookie(resp: &mut Response) {
3728    set_cookie(
3729        resp,
3730        &format!("{OAUTH_BINDING_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
3731    );
3732}
3733
3734/// Form body for `POST /login`.
3735#[derive(Debug, Deserialize)]
3736struct LoginForm {
3737    handle: String,
3738}
3739
3740/// Query for `GET /oauth/callback`.
3741///
3742/// Carries BOTH shapes, because the two backends deliver different things to
3743/// the same URL: the sidecar hands back a one-shot `session_id` it has already
3744/// exchanged, while the PDS redirects here directly with `code`/`state`/`iss`
3745/// for this app to exchange itself. Which fields are populated is decided by
3746/// which backend started the login, not by which is live now — so a flip with a
3747/// login already in flight still lands in the right arm.
3748#[derive(Debug, Deserialize, Default)]
3749struct CallbackQuery {
3750    /// Sidecar backend: the handoff id.
3751    #[serde(default)]
3752    session_id: Option<String>,
3753    /// Rust backend: the authorization code and its envelope.
3754    #[serde(default)]
3755    code: Option<String>,
3756    #[serde(default)]
3757    state: Option<String>,
3758    #[serde(default)]
3759    iss: Option<String>,
3760    /// JARM, which is not supported — carried only so it can be refused
3761    /// explicitly rather than read as "no code".
3762    #[serde(default)]
3763    response: Option<String>,
3764    #[serde(default)]
3765    error: Option<String>,
3766    #[serde(default)]
3767    error_description: Option<String>,
3768}
3769
3770/// `GET /oauth/callback` — establish the cookie session.
3771///
3772/// **Invite gate:** the verified DID must hold beta access. If it already does
3773/// (existing member / seeded admin) it's admitted directly. Otherwise we bind
3774/// the DID to the reserved invite cookie: `redeem_code` atomically consumes the
3775/// code and grants the seat. A DID with neither is bounced to `/beta/redeem`.
3776async fn oauth_callback(
3777    State(state): State<AppState>,
3778    headers: HeaderMap,
3779    Query(q): Query<CallbackQuery>,
3780) -> Response {
3781    // An error response is handled by the SAME arm that would have handled a
3782    // success, not short-circuited here.
3783    //
3784    // Returning early looks obviously right and is wrong on the Rust path: it
3785    // skips `verify_callback`, which validates `iss` BEFORE reporting the error
3786    // precisely because RFC 9207 §2.4 says a client "MUST NOT assume that the
3787    // error originates from the intended AS". It also leaves the pending row
3788    // unconsumed, so a `state` that has already produced a callback stays usable
3789    // until it expires.
3790    //
3791    // The sidecar arm has no such check to reach, so it is short-circuited
3792    // below, preserving exactly what it did before.
3793    // **The arm is chosen by what the SERVER knows, not by what the caller
3794    // sent.** A `session_id` in the query used to select the sidecar arm on its
3795    // own — so a caller could pick which code path ran, and the sidecar arm has
3796    // no browser-binding check at all. It also short-circuited the error path
3797    // below, skipping the `iss` validation.
3798    //
3799    // Requiring the Rust runtime to be absent, or a sidecar backend to be the
3800    // configured one, means the selection follows this deployment's own
3801    // configuration. A login started before a flip still completes, because the
3802    // Rust arm is reached whenever the Rust runtime exists and can match the
3803    // `state` against a pending row it actually wrote.
3804    // The sidecar hands off in TWO shapes, not one: `?session_id=…` on success
3805    // and `?error=…&error_description=…` on its own failure. Keying only on
3806    // `session_id` sent the failure shape down the Rust arm, which then failed
3807    // with "no `state`" and replaced the specific reason with a generic one —
3808    // and `error_description` is exactly what the sidecar Caddy routing matches
3809    // to send that request here in the first place.
3810    let sidecar_shape =
3811        q.session_id.as_deref().is_some_and(|s| !s.is_empty()) || q.error_description.is_some();
3812    let sidecar_handoff = sidecar_shape
3813        && (state.oauth.is_none() || state.config.repo_backend == crate::metrics::Backend::Sidecar);
3814    if let Some(err) = q.error.clone() {
3815        // **Neither the code nor the description is echoed as sent.**
3816        //
3817        // Both are server-controlled free text arriving on a public GET, so
3818        // anyone who can make a browser fetch this URL chooses them. The raw
3819        // `error` used to go into a `warn!` AND into the rendered login page,
3820        // and `error_description` — arbitrary text, newlines included — went
3821        // into the log verbatim: a log-injection surface on one side and
3822        // attacker-chosen copy in the product's own voice on the other.
3823        //
3824        // `oauth::flow` already decided this exact question for the Rust arm:
3825        // reduce the code to a known slug, drop the description entirely. That
3826        // reasoning is not specific to which arm handles the callback, and this
3827        // one simply never got the same treatment. The description's LENGTH is
3828        // kept, because "the server sent a 4 KB explanation" is occasionally
3829        // worth knowing and cannot be used to inject anything.
3830        let slug = crate::oauth::flow::known_error_slug(&err);
3831        warn!(
3832            error = slug,
3833            desc_len = q.error_description.as_deref().map_or(0, str::len),
3834            "OAuth callback returned an error"
3835        );
3836        if sidecar_handoff || state.oauth.is_none() {
3837            return login_error(&format!("Login failed: {slug}"));
3838        }
3839        // Fall through: the Rust arm consumes the pending row and validates
3840        // `iss` against it, and reports the failure afterwards.
3841    }
3842
3843    // Which arm runs is decided by WHAT ARRIVED, not by which backend is
3844    // currently selected: a login started before a flip must still complete.
3845    let session = if sidecar_handoff {
3846        let session_id = q.session_id.clone().unwrap_or_default();
3847        match state.sidecar.resolve_session(&session_id).await {
3848            Ok(Some(s)) => s,
3849            Ok(None) => {
3850                warn!("OAuth callback session_id did not resolve (expired/unknown)");
3851                return login_error("Login session expired — please try again.");
3852            }
3853            Err(err) => {
3854                warn!(%err, "failed to resolve OAuth session via the sidecar");
3855                return login_error("Login failed talking to the auth service.");
3856            }
3857        }
3858    } else {
3859        let Some(runtime) = state.oauth.as_deref() else {
3860            warn!("an OAuth callback arrived with no sidecar session and no Rust runtime");
3861            return login_error("Login failed: this login could not be completed.");
3862        };
3863        let params = crate::oauth::flow::CallbackParams {
3864            code: q.code.clone(),
3865            state: q.state.clone(),
3866            iss: q.iss.clone(),
3867            // Passed through, NOT dropped: `verify_callback` checks `iss`
3868            // against the pending row's issuer before it reports the error, and
3869            // it cannot do that for an error it never sees.
3870            error: q.error.clone(),
3871            error_description: q.error_description.clone(),
3872            response: q.response.clone(),
3873        };
3874        let binding =
3875            cookie::verify_value(&headers, OAUTH_BINDING_COOKIE, &state.config.cookie_secret);
3876        match crate::oauth::login::complete(
3877            runtime,
3878            &state.http,
3879            &state.db,
3880            &params,
3881            binding.as_deref(),
3882            crate::store::now_unix(),
3883        )
3884        .await
3885        {
3886            Ok(done) => crate::atproto::SidecarSession {
3887                did: done.did,
3888                handle: done.handle,
3889            },
3890            Err(err) => {
3891                // Never echoed to the browser: the message can name the issuer,
3892                // the PDS, and why a binding check failed.
3893                warn!(%err, "could not complete the OAuth callback");
3894                let mut resp = login_error("Login failed — please try again.");
3895                clear_binding_cookie(&mut resp);
3896                return resp;
3897            }
3898        }
3899    };
3900
3901    // Bind the verified DID to the invite gate. Returns a response only on the
3902    // (rare) failure paths; `Ok(())` means the DID now holds beta access.
3903    let mut clear_invite = false;
3904    if !store::has_beta_access(&state.db, &session.did)
3905        .await
3906        .unwrap_or(false)
3907    {
3908        // Not yet a member: consume the reserved invite code, if any.
3909        let code = match invite_cookie_code(&headers, &state.config.cookie_secret) {
3910            Some(c) => c,
3911            None => {
3912                warn!(did = %session.did, "OAuth callback with no beta access and no invite cookie");
3913                return Redirect::to("/beta/redeem").into_response();
3914            }
3915        };
3916        match store::redeem_code(
3917            &state.db,
3918            &code,
3919            &session.did,
3920            session.handle.as_deref(),
3921            state.config.beta_cap,
3922        )
3923        .await
3924        {
3925            Ok(Ok(())) => {
3926                clear_invite = true;
3927                info!(did = %session.did, "invite code redeemed at OAuth callback; beta access granted");
3928            }
3929            Ok(Err(policy)) => {
3930                warn!(did = %session.did, ?policy, "invite redeem failed at callback");
3931                let mut resp = redeem_bounce(&policy).into_response();
3932                // The reservation is spent/invalid — drop the stale invite cookie.
3933                clear_invite_cookie(&mut resp);
3934                return resp;
3935            }
3936            Err(err) => {
3937                warn!(%err, did = %session.did, "invite redeem infra error at callback");
3938                return login_error("Login failed while confirming your invite.");
3939            }
3940        }
3941    }
3942
3943    // Mint an opaque, random server-side session id and store the identity under
3944    // it; the cookie carries the (HMAC-signed) sid, never the DID.
3945    let sid = state.sessions.create(Session {
3946        did: session.did.clone(),
3947        handle: session.handle.clone(),
3948    });
3949    let cookie = cookie::sign_session(&sid, &state.config.cookie_secret);
3950    info!(did = %session.did, handle = ?session.handle, "OAuth login OK; session cookie set");
3951
3952    let mut resp = Redirect::to("/").into_response();
3953    set_cookie(&mut resp, &cookie);
3954    clear_binding_cookie(&mut resp);
3955    if clear_invite {
3956        clear_invite_cookie(&mut resp);
3957    }
3958    resp
3959}
3960
3961/// Revoke a DID's OAuth session on BOTH backends, best-effort.
3962///
3963/// Not "whichever backend is live": during a cutover a user's tokens can be in
3964/// either store — they logged in under one backend and are logging out under
3965/// the other. Revoking only the live one would leave a live refresh token
3966/// behind in the other, which is the exact failure sign-out exists to prevent,
3967/// and it would be invisible because the sign-out itself looks successful.
3968///
3969/// Both arms are best-effort. The caller has already decided to sign the user
3970/// out, and a network failure must not trap them in a half-logged-out state.
3971/// How long sign-out will wait for a final read-state flush before revoking
3972/// anyway.
3973///
3974/// Bounded because the flush talks to the user's PDS, and a user trying to leave
3975/// must never be held by a server that is not answering. Three seconds is long
3976/// enough for a healthy `applyWrites` (the production samples run 100–970 ms)
3977/// and short enough that a dead PDS is an inconvenience rather than a trap.
3978const SIGN_OUT_FLUSH_BUDGET: std::time::Duration = std::time::Duration::from_secs(3);
3979
3980/// Flush whatever read-state is still dirty for `did`, then give up quietly.
3981///
3982/// **Called before revoking, because revoking first strands it (#117).**
3983/// `revoke_everywhere` deletes the OAuth session, and a dirty cursor with no
3984/// session cannot be sent by anyone — it parks until the user signs in again,
3985/// which may be never. Flushing first is what stops the common case from
3986/// becoming that.
3987///
3988/// Best-effort by construction: every failure path here falls through to the
3989/// revoke. A flush that times out or errors leaves the cursors dirty, which is
3990/// the parked state the flusher now handles deliberately rather than retrying
3991/// forever.
3992async fn flush_before_revoke(state: &AppState, did: &str) {
3993    match tokio::time::timeout(
3994        SIGN_OUT_FLUSH_BUDGET,
3995        crate::readstate::flush_did(state, did),
3996    )
3997    .await
3998    {
3999        Ok(Ok(())) => {}
4000        Ok(Err(err)) => {
4001            warn!(%did, %err, "sign-out: final read-state flush failed; it will park until next sign-in")
4002        }
4003        Err(_) => warn!(
4004            %did,
4005            budget = ?SIGN_OUT_FLUSH_BUDGET,
4006            "sign-out: final read-state flush timed out; it will park until next sign-in"
4007        ),
4008    }
4009}
4010
4011async fn revoke_everywhere(state: &AppState, did: &str) {
4012    // **Counted under Backend::Sidecar, not left uncounted.** A review found
4013    // that recording only the rust arm let `oauth_revoke` report a clean success
4014    // while every sidecar revocation failed — and for anyone who logged in before
4015    // the cutover, the sidecar store is the ONLY one that held tokens, so the
4016    // rust arm correctly returns NoSession and the metric reads all-clear while
4017    // live refresh tokens sit at the PDS.
4018    //
4019    // Same op name, different backend: the backend column is what distinguishes
4020    // them, so "no revocation failures" means checking both rows, not one.
4021    let sidecar_started = std::time::Instant::now();
4022    let sidecar_ok = match state.sidecar.revoke_session(did).await {
4023        Ok(res) => {
4024            info!(%did, revoked = res.revoked, "sidecar session revoked");
4025            true
4026        }
4027        Err(err) => {
4028            warn!(%did, %err, "sidecar revoke failed; continuing");
4029            false
4030        }
4031    };
4032    state.metrics.record(
4033        crate::metrics::Backend::Sidecar,
4034        "oauth_revoke",
4035        sidecar_started.elapsed().as_micros() as u64,
4036        sidecar_ok,
4037    );
4038
4039    if let Some(runtime) = state.oauth.as_deref() {
4040        let revoke_started = std::time::Instant::now();
4041        let outcome = crate::oauth::revoke::sign_out_discovering(
4042            runtime,
4043            &state.http,
4044            &state.db,
4045            did,
4046            crate::store::now_unix(),
4047        )
4048        .await;
4049        // **Counted, because a warn! nobody reads is not observability.** Until
4050        // this existed, a revocation failure left exactly one trace: a log line.
4051        // "No revocation failures this week" was therefore a statement about
4052        // nobody having looked, which is not the same claim.
4053        //
4054        // NoSession counts as a SUCCESS, deliberately. Logout is idempotent —
4055        // there being nothing to revoke is the correct outcome, not a failure,
4056        // and counting it as an error would make the metric noisy in exactly
4057        // the case that is fine. Only `Failed` means the PDS still holds live
4058        // tokens we asked it to drop.
4059        let revoke_ok = !matches!(outcome, crate::oauth::revoke::Revocation::Failed(_));
4060        state.metrics.record(
4061            crate::metrics::Backend::Rust,
4062            "oauth_revoke",
4063            revoke_started.elapsed().as_micros() as u64,
4064            revoke_ok,
4065        );
4066        match outcome {
4067            crate::oauth::revoke::Revocation::Revoked => {
4068                info!(%did, "rust OAuth session revoked at the PDS")
4069            }
4070            crate::oauth::revoke::Revocation::NoSession => {}
4071            crate::oauth::revoke::Revocation::Failed(reason) => {
4072                warn!(%did, %reason, "rust OAuth revoke failed; the local session is gone regardless")
4073            }
4074        }
4075    }
4076}
4077
4078/// `POST /logout` — end the session everywhere, not just in this browser.
4079///
4080/// Clearing the cookie only stops *this* device from presenting the session;
4081/// the sidecar still holds live OAuth tokens for the DID. So logout now also
4082/// calls the sidecar `POST /internal/revoke {did}`, which revokes the refresh +
4083/// access tokens at the PDS and drops the sidecar's session rows. The local
4084/// registry entry is dropped and the cookie cleared regardless of whether the
4085/// revoke round-trip succeeds (best-effort — a network blip must not trap the
4086/// user in a half-logged-out state).
4087async fn logout(State(state): State<AppState>, headers: HeaderMap) -> Response {
4088    if let Some(user) = current_session(&state, &headers).await {
4089        // Only a real cookie session (`sid` present) has sidecar-held tokens to
4090        // revoke; the dev-DID fallback never handshook the sidecar.
4091        if let Some(sid) = user.sid {
4092            state.sessions.remove(&sid);
4093            // BEFORE the revoke: afterwards there is no session to send it with.
4094            flush_before_revoke(&state, &user.did).await;
4095            revoke_everywhere(&state, &user.did).await;
4096        }
4097    }
4098    let mut resp = Redirect::to("/login").into_response();
4099    set_cookie(
4100        &mut resp,
4101        &format!("{SESSION_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
4102    );
4103    resp
4104}
4105
4106/// Form body for `POST /account/delete` — the confirm-gate. The user must type
4107/// `DELETE` into this field for the purge to run.
4108#[derive(Debug, Deserialize)]
4109struct DeleteAccountForm {
4110    #[serde(default)]
4111    confirm: String,
4112}
4113
4114/// The literal a user must type to confirm the destructive delete.
4115const DELETE_CONFIRM_PHRASE: &str = "DELETE";
4116
4117/// `POST /account/delete` (authed) — the "delete my data" endpoint.
4118///
4119/// Confirm-gated: the form must carry `confirm=DELETE` or we bounce back to
4120/// `/manage` with an explanatory flash and touch nothing. On confirmation it:
4121///   1. purges **every** local row owned by the caller DID (`entry_state`,
4122///      `read_cursor`, `sub_ref`, `beta_access` seat, and any invite codes the
4123///      DID created) via [`store::purge_did_data`], then
4124///   2. calls the sidecar `POST /internal/revoke {did}` so the OAuth tokens are
4125///      revoked at the PDS and the sidecar's session rows are dropped, then
4126///   3. drops the in-memory session and clears the cookie, signing the user out.
4127///
4128/// The subscription/folder/saved *records* in the user's own PDS are
4129/// intentionally left alone — they are the user's data on their own server; the
4130/// `/about` copy and this page's UI both say so, and export stays available.
4131async fn account_delete(
4132    State(state): State<AppState>,
4133    headers: HeaderMap,
4134    Form(form): Form<DeleteAccountForm>,
4135) -> Result<Response, WebError> {
4136    let user = match current_session(&state, &headers).await {
4137        Some(u) => u,
4138        None => return Ok(Redirect::to("/login").into_response()),
4139    };
4140    let did = user.did.clone();
4141
4142    // Confirm-gate: require the exact typed phrase before doing anything.
4143    if form.confirm.trim() != DELETE_CONFIRM_PHRASE {
4144        return Ok(Redirect::to(&format!(
4145            "/manage?flash={}",
4146            qenc("Type DELETE to confirm — nothing was deleted.")
4147        ))
4148        .into_response());
4149    }
4150
4151    // 1. Purge every local row this DID owns (single transaction).
4152    let counts = store::purge_did_data(&state.db, &did).await?;
4153    info!(
4154        %did,
4155        total = counts.total(),
4156        entry_state = counts.entry_state,
4157        read_cursor = counts.read_cursor,
4158        sub_ref = counts.sub_ref,
4159        beta_access = counts.beta_access,
4160        invite_codes = counts.invite_codes,
4161        "account/delete: local rows purged"
4162    );
4163
4164    // 2. Revoke the OAuth session at the sidecar/PDS (best-effort — the local
4165    //    rows are already gone; a network blip must not block the sign-out).
4166    revoke_everywhere(&state, &did).await;
4167
4168    // 3. Drop the in-memory session and clear the cookie: sign the user out.
4169    if let Some(sid) = user.sid {
4170        state.sessions.remove(&sid);
4171    }
4172    let mut resp = Redirect::to(&format!(
4173        "/login?flash={}",
4174        qenc("Your data was deleted and you've been signed out. Thanks for trying FeatherReader.")
4175    ))
4176    .into_response();
4177    set_cookie(
4178        &mut resp,
4179        &format!("{SESSION_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
4180    );
4181    Ok(resp)
4182}
4183
4184/// Re-render the login form with an error banner.
4185fn login_error(msg: &str) -> Response {
4186    render(&LoginTemplate {
4187        repo_url: REPO_URL,
4188        error: msg.to_string(),
4189        flash: String::new(),
4190    })
4191}
4192
4193// ---------------------------------------------------------------------------
4194// Closed-beta invite gate (self-serve redeem + admin mint)
4195// ---------------------------------------------------------------------------
4196
4197/// Form body for `POST /beta/redeem`.
4198#[derive(Debug, Deserialize)]
4199struct RedeemForm {
4200    code: String,
4201}
4202
4203/// `GET /beta/redeem` — render the invite-redeem page. If the seat cap is
4204/// already full we render the "capacity full" variant (no form).
4205async fn beta_redeem_form(State(state): State<AppState>) -> Response {
4206    let full = store::count_beta_access(&state.db)
4207        .await
4208        .map(|n| n >= state.config.beta_cap)
4209        .unwrap_or(false);
4210    render(&BetaRedeemTemplate {
4211        repo_url: REPO_URL,
4212        error: String::new(),
4213        capacity_full: full,
4214    })
4215}
4216
4217/// `POST /beta/redeem` — the **pre-handshake** reservation.
4218///
4219/// Validates the pasted code is *redeemable right now* (exists, active,
4220/// unexpired, and a seat is free) WITHOUT consuming it or binding a DID — the
4221/// visitor has no DID yet. On success it sets a short-lived signed invite cookie
4222/// reserving intent to redeem this code, then sends the visitor to `/login`. The
4223/// OAuth callback later binds the verified DID and atomically consumes the code
4224/// (`store::redeem_code`). This ordering means a non-invited visitor can never
4225/// start OAuth (and burn a sidecar handshake).
4226async fn beta_redeem_submit(
4227    State(state): State<AppState>,
4228    Form(form): Form<RedeemForm>,
4229) -> Response {
4230    let code = form.code.trim().to_uppercase();
4231    if code.is_empty() {
4232        return render(&BetaRedeemTemplate {
4233            repo_url: REPO_URL,
4234            error: "Enter your invite code.".to_string(),
4235            capacity_full: false,
4236        });
4237    }
4238
4239    match preflight_code(&state, &code).await {
4240        Ok(()) => {
4241            let cookie = sign_invite(&code, &state.config.cookie_secret);
4242            let mut resp = Redirect::to("/login").into_response();
4243            set_cookie(&mut resp, &cookie);
4244            info!("invite code preflight OK; reserving intent + redirecting to /login");
4245            resp
4246        }
4247        Err(policy) => {
4248            warn!(?policy, "invite code preflight rejected");
4249            redeem_bounce(&policy)
4250        }
4251    }
4252}
4253
4254/// Read-only preflight of an invite code for the pre-handshake reservation:
4255/// verify it exists, is active, is not past `expires_at`, and that a seat is
4256/// free — mirroring the checks `store::redeem_code` will re-run atomically at
4257/// callback time. Does NOT consume the code or grant a seat. Returns the same
4258/// typed [`store::RedeemError`] variants so the two paths share one message map.
4259async fn preflight_code(state: &AppState, code: &str) -> Result<(), store::RedeemError> {
4260    // Cap check first: a clear "capacity full" beats "code invalid" when both.
4261    // FAIL CLOSED on a count error — an `unwrap_or(0)` would let a DB blip read as
4262    // "0 seats used" and wave the redeem through the preflight. (`redeem_code`
4263    // still backstops the real cap inside its tx, so this is a consistency /
4264    // defence-in-depth fix, not the only guard.) Treat an unverifiable count as
4265    // capacity-full: the redeemer sees "at capacity, try later" rather than a mint
4266    // that might overrun the cap.
4267    let count = match store::count_beta_access(&state.db).await {
4268        Ok(n) => n,
4269        Err(err) => {
4270            warn!(%err, "preflight_code: count_beta_access failed; failing closed");
4271            return Err(store::RedeemError::CapacityFull);
4272        }
4273    };
4274    if count >= state.config.beta_cap {
4275        return Err(store::RedeemError::CapacityFull);
4276    }
4277    // Look up the code's current status + expiry (read-only).
4278    let row = sqlx::query_as::<_, (String, i64)>(
4279        "SELECT status, expires_at FROM invite_codes WHERE code = ?1",
4280    )
4281    .bind(code)
4282    .fetch_optional(&state.db)
4283    .await
4284    .ok()
4285    .flatten();
4286    let (status, expires_at) = match row {
4287        Some(r) => r,
4288        None => return Err(store::RedeemError::NotFound),
4289    };
4290    let now = chrono::Utc::now().timestamp();
4291    match status.as_str() {
4292        "active" if expires_at >= now => Ok(()),
4293        "active" => Err(store::RedeemError::Expired),
4294        "expired" => Err(store::RedeemError::Expired),
4295        // "redeemed" or anything else non-active.
4296        _ => Err(store::RedeemError::AlreadyRedeemed),
4297    }
4298}
4299
4300/// Map a [`store::RedeemError`] to the invite page with the right message. Used
4301/// by both the preflight (`POST /beta/redeem`) and the callback bind path.
4302fn redeem_bounce(policy: &store::RedeemError) -> Response {
4303    use store::RedeemError::*;
4304    let (msg, capacity_full) = match policy {
4305        NotFound => ("That invite code isn't valid.", false),
4306        Expired => ("That invite code has expired.", false),
4307        AlreadyRedeemed => ("That invite code has already been used.", false),
4308        CapacityFull => ("", true),
4309    };
4310    render(&BetaRedeemTemplate {
4311        repo_url: REPO_URL,
4312        error: msg.to_string(),
4313        capacity_full,
4314    })
4315}
4316
4317/// Query for `POST /admin/invites` — how many codes to mint (`?n=`, default 1).
4318#[derive(Debug, Deserialize, Default)]
4319struct MintQuery {
4320    #[serde(default)]
4321    n: Option<u32>,
4322}
4323
4324/// `POST /admin/invites?n=N` — mint N invite codes.
4325///
4326/// `GET /oauth/client-metadata.json` — the client's published identity.
4327///
4328/// **This URL IS the `client_id`.** The PDS fetches it during every login and
4329/// caches it against every existing grant, so it must keep answering at exactly
4330/// this path across the cutover — the sidecar serves the same document at the
4331/// same URL today, proxied by the edge.
4332///
4333/// Served whatever backend is live: a request that arrives here is from a PDS
4334/// resolving our identity, and it has no idea which of our two implementations
4335/// is currently answering repo calls.
4336async fn oauth_client_metadata(State(state): State<AppState>) -> Response {
4337    let Some(runtime) = state.oauth.as_deref() else {
4338        // The sidecar is serving this path in front of us, or nothing is.
4339        return (StatusCode::NOT_FOUND, "no client metadata\n").into_response();
4340    };
4341    axum::Json(crate::oauth::metadata::client_metadata(&runtime.client)).into_response()
4342}
4343
4344/// `GET /oauth/jwks.json` — the client's public signing key.
4345///
4346/// Production only. The localhost dev client is a PUBLIC client: it registers no
4347/// key and signs no assertions, so publishing a JWKS there would advertise a
4348/// credential that is never used — and would make a dev deployment look like a
4349/// confidential client to anyone reading it.
4350async fn oauth_jwks(State(state): State<AppState>) -> Response {
4351    let Some(runtime) = state.oauth.as_deref() else {
4352        return (StatusCode::NOT_FOUND, "no jwks\n").into_response();
4353    };
4354    match runtime.client_key.as_ref() {
4355        Some(key) => match key.jwks_document() {
4356            Ok(doc) => axum::Json(doc).into_response(),
4357            Err(err) => {
4358                warn!(%err, "could not render the client JWKS");
4359                (StatusCode::INTERNAL_SERVER_ERROR, "jwks unavailable\n").into_response()
4360            }
4361        },
4362        None => (StatusCode::NOT_FOUND, "this client publishes no jwks\n").into_response(),
4363    }
4364}
4365
4366/// How many failing feeds `/admin/metrics` will name. One response, so bounded.
4367const ADMIN_FAILING_FEED_LIMIT: i64 = 200;
4368
4369/// `GET /admin/metrics` — repo-op latency for both backends, as plain text.
4370///
4371/// Admin-gated on the same rule as the invite minter: the table names every
4372/// operation the reader performs and how often each fails, which is an
4373/// operational picture rather than public information.
4374///
4375/// Text, not JSON or HTML: it is read by a person deciding whether the cutover
4376/// is safe, and the comparison is two rows side by side.
4377async fn admin_metrics(State(state): State<AppState>, headers: HeaderMap) -> Response {
4378    let did = match current_did(&state, &headers).await {
4379        Some(d) => d,
4380        None => return (StatusCode::UNAUTHORIZED, "sign in first\n").into_response(),
4381    };
4382    if !state.config.admin_seed_dids().iter().any(|d| d == &did) {
4383        warn!(%did, "admin metrics denied: not an admin-seed DID");
4384        return (StatusCode::FORBIDDEN, "not an admin\n").into_response();
4385    }
4386
4387    // Flush first, so the table includes this process's traffic up to now.
4388    // Then read the PERSISTED rows, which is the only place both backends can
4389    // appear at once -- a flip is a restart, and in-process memory only ever
4390    // holds the backend currently running.
4391    if let Err(err) =
4392        crate::metrics::flush(&state.metrics, &state.db, crate::store::now_unix()).await
4393    {
4394        warn!(%err, "could not flush repo timings before rendering");
4395    }
4396    let rows = match crate::metrics::persisted_rows(&state.db).await {
4397        Ok(rows) => rows,
4398        Err(err) => {
4399            warn!(%err, "could not read persisted repo timings");
4400            return (StatusCode::INTERNAL_SERVER_ERROR, "metrics unavailable\n").into_response();
4401        }
4402    };
4403
4404    // The live backend is named at the top: a table of two populated rows is
4405    // ambiguous about which one is currently serving users.
4406    // Parked read-state, alongside the timings. The flusher no longer logs
4407    // these every round (#117), so without a number here the state would be
4408    // silent — which is the failure the noisy loop at least did not have.
4409    let parked = match crate::store::parked_readstate_dids(&state.db).await {
4410        Ok(n) => n.to_string(),
4411        Err(err) => {
4412            warn!(%err, "could not count parked read-state DIDs");
4413            "unknown".to_string()
4414        }
4415    };
4416    // **The half the public histogram cannot carry.** `/stats` reports counts by
4417    // cause and nothing else, deliberately — but `fetch` covers DNS failure,
4418    // timeout, SSRF refusal AND this reader's own bugs, so the count alone
4419    // cannot separate "the publishers are gone" from "we are broken". #159 was
4420    // the latter and took a production investigation to establish. Named feeds
4421    // and their error text belong here, behind ALLOWED_DIDS.
4422    let failing = match crate::store::failing_feeds(&state.db, ADMIN_FAILING_FEED_LIMIT).await {
4423        Ok(f) => f,
4424        Err(err) => {
4425            warn!(%err, "could not list failing feeds");
4426            Vec::new()
4427        }
4428    };
4429    let mut failing_block = String::new();
4430    if !failing.is_empty() {
4431        failing_block.push_str("\nfailing feeds (worst first)\n");
4432        for f in &failing {
4433            failing_block.push_str(&format!(
4434                "  {:>4}x  {:<8}  {}\n          {}\n",
4435                f.consecutive_errors,
4436                f.kind.as_deref().unwrap_or("unknown"),
4437                f.url,
4438                f.detail.as_deref().unwrap_or("(no detail recorded)"),
4439            ));
4440        }
4441    }
4442
4443    let body = format!(
4444        "live backend: {}\nparked read-state DIDs: {}\n\n{}{}",
4445        state.config.repo_backend.as_str(),
4446        parked,
4447        crate::metrics::render(&rows),
4448        failing_block,
4449    );
4450    (StatusCode::OK, body).into_response()
4451}
4452
4453/// Authorized ONLY for a live session whose DID is in the `ALLOWED_DIDS` admin
4454/// seed (`config.admin_seed_dids`). Returns the freshly-minted codes as
4455/// newline-separated `text/plain`. Deliberately minimal (no HTML UI).
4456async fn admin_mint_invites(
4457    State(state): State<AppState>,
4458    headers: HeaderMap,
4459    Query(q): Query<MintQuery>,
4460) -> Response {
4461    // Require a real, current session (not just a DID string) whose DID is an
4462    // admin-seed DID. `current_did` already re-checks the beta gate.
4463    let did = match current_did(&state, &headers).await {
4464        Some(d) => d,
4465        None => return (StatusCode::UNAUTHORIZED, "sign in first\n").into_response(),
4466    };
4467    if !state.config.admin_seed_dids().iter().any(|d| d == &did) {
4468        warn!(%did, "admin mint denied: not an admin-seed DID");
4469        return (StatusCode::FORBIDDEN, "not an admin\n").into_response();
4470    }
4471
4472    let n = q.n.unwrap_or(1).clamp(1, 100);
4473    let mut codes = Vec::with_capacity(n as usize);
4474    for _ in 0..n {
4475        match store::mint_code(&state.db, &did, INVITE_TTL_SECS).await {
4476            Ok(code) => codes.push(code),
4477            Err(err) => {
4478                warn!(%err, %did, "admin mint_code failed");
4479                return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
4480            }
4481        }
4482    }
4483    info!(%did, count = codes.len(), "admin minted invite codes");
4484    let mut body = codes.join("\n");
4485    body.push('\n');
4486    (StatusCode::OK, body).into_response()
4487}
4488
4489// ---------------------------------------------------------------------------
4490// Bot claim link: /claim?t=<token>  +  POST /bot/claims (shared-secret mint)
4491// ---------------------------------------------------------------------------
4492
4493/// Query for `GET /claim`.
4494#[derive(Debug, Deserialize)]
4495struct ClaimQuery {
4496    /// The opaque claim token from the bot's public follow-back skeet.
4497    t: Option<String>,
4498}
4499
4500/// `GET /claim?t=<token>` — redeem a bot-issued claim link.
4501///
4502/// The follow→invite bot posts a public skeet mentioning a new follower with a
4503/// link here. The token wraps a pre-minted invite code (never the raw code — see
4504/// [`sign_claim_token`]). On a valid, still-redeemable token this behaves exactly
4505/// like a successful `POST /beta/redeem`: it sets the reserving `fr_invite`
4506/// cookie and sends the visitor to `/login`, so they flow through OAuth and the
4507/// callback atomically consumes the code (`store::redeem_code`) — the same
4508/// machinery as a pasted code. On any failure it bounces to the invite page with
4509/// the matching message.
4510///
4511/// Single-use / grabbability: a token in a public URL is grabbable. The code it
4512/// wraps is single-use (redeem flips `active→redeemed`), and `preflight_code`
4513/// here rejects an already-used / expired / capacity-full code before reserving,
4514/// so a replayed link past the first successful claim is refused. The residual
4515/// window is the same as any pasted invite code: whoever completes OAuth *first*
4516/// with a live reservation wins the seat. The per-IP rate limit on `/claim`
4517/// blunts brute-force enumeration.
4518async fn claim(State(state): State<AppState>, Query(q): Query<ClaimQuery>) -> Response {
4519    let token = match q.t {
4520        Some(t) if !t.is_empty() => t,
4521        _ => {
4522            warn!("claim link with no token");
4523            return redeem_bounce(&store::RedeemError::NotFound);
4524        }
4525    };
4526
4527    // Unwrap the token → the invite code it reserves. A tampered/forged token
4528    // yields nothing → treat as an invalid code (don't leak whether it parsed).
4529    let code = match claim_token_code(&token, &state.config.cookie_secret) {
4530        Some(c) => c,
4531        None => {
4532            warn!("claim token invalid (bad signature / malformed)");
4533            return redeem_bounce(&store::RedeemError::NotFound);
4534        }
4535    };
4536
4537    // Re-run the same preflight as the pasted-code path: exists, active,
4538    // unexpired, seat free. This is what makes a replayed link past first-claim
4539    // (or past cap) fail cleanly.
4540    match preflight_code(&state, &code).await {
4541        Ok(()) => {
4542            let cookie = sign_invite(&code, &state.config.cookie_secret);
4543            let mut resp = Redirect::to("/login").into_response();
4544            set_cookie(&mut resp, &cookie);
4545            info!("claim token preflight OK; reserving intent + redirecting to /login");
4546            resp
4547        }
4548        Err(policy) => {
4549            warn!(?policy, "claim token preflight rejected");
4550            redeem_bounce(&policy)
4551        }
4552    }
4553}
4554
4555/// The JSON body `POST /bot/claims` accepts — the follower the claim is FOR.
4556///
4557/// Passing the follower DID makes the APP the authoritative deduper: the app can
4558/// short-circuit a DID that already holds a seat, and return the SAME code for a
4559/// DID that already has an outstanding claim — so a bot-host state loss cannot
4560/// re-mint or re-post per follower. Handle is advisory (logs only).
4561#[derive(Debug, Default, Deserialize)]
4562struct BotClaimRequest {
4563    /// The follower's DID (the idempotency key). Optional for backward-compat: an
4564    /// omitted DID falls back to the old un-keyed mint (no server-side dedupe).
4565    #[serde(default)]
4566    did: Option<String>,
4567    /// The follower's handle (advisory; recorded for operator logs only).
4568    #[serde(default)]
4569    #[allow(dead_code)]
4570    handle: Option<String>,
4571}
4572
4573/// The JSON body `POST /bot/claims` returns on success.
4574#[derive(Debug, serde::Serialize)]
4575struct BotClaimResponse {
4576    /// Server-side dedupe outcome, so the bot knows whether to post:
4577    /// `"minted"` (a fresh code — post the claim link), `"existing"` (this DID
4578    /// already had an outstanding claim; the SAME code/token/url is returned, so an
4579    /// idempotent re-post is safe), or `"already_seated"` (this DID already holds
4580    /// beta access; code/token/url are empty and the bot should post NOTHING).
4581    status: &'static str,
4582    /// The bare invite code (`FEATHER-…`) — for the bot's own logs/idempotency
4583    /// store. NEVER post this publicly; post the `url` instead. Empty when
4584    /// `already_seated`.
4585    code: String,
4586    /// The opaque claim token (the code wrapped + signed). Empty when
4587    /// `already_seated`.
4588    token: String,
4589    /// The full claim URL to put in the public skeet: `${public_url}/claim?t=…`.
4590    /// Empty when `already_seated`.
4591    url: String,
4592}
4593
4594/// `POST /bot/claims` — headless, shared-secret mint of a claim link.
4595///
4596/// Auth is a bearer shared secret in the `X-Bot-Secret` header (== the Fly secret
4597/// `FEATHERREADER_BOT_SECRET`), NOT an OAuth cookie — so the homelab-hosted bot
4598/// can call it. When `FEATHERREADER_BOT_SECRET` is unset the endpoint is DISABLED
4599/// (503), so a bare/dev instance never exposes an unauthenticated mint.
4600///
4601/// Server-side DID idempotency (the authoritative dedupe backstop): the request
4602/// body carries the follower `did`. The app — not the bot's local SQLite — is the
4603/// source of truth, so a bot-host state loss cannot re-mint or re-post per
4604/// follower:
4605///   * DID already holds beta access → `200 {status:"already_seated"}` (empty
4606///     code/url; the bot marks it handled and posts NOTHING);
4607///   * DID already has an outstanding active claim → `200 {status:"existing"}`
4608///     returning the SAME code/token/url (idempotent — never a second mint);
4609///   * otherwise mint a fresh code recorded FOR that DID → `200 {status:"minted"}`.
4610///
4611/// Cap accounting: the bot must not promise more claims than seats remain, so
4612/// this refuses with `409 Conflict {"error":"full"}` when
4613/// `beta_access + outstanding active codes >= FEATHERREADER_BETA_CAP`. (The
4614/// redeem-time cap in `store::redeem_code` is still the hard backstop.) The count
4615/// queries FAIL CLOSED: a DB error propagates as `500` rather than reading 0 and
4616/// minting past the cap.
4617///
4618/// On a fresh mint it uses the generous claim TTL (`FEATHERREADER_CLAIM_TTL_SECS`,
4619/// default 14d — the admin browser flow's 30-min TTL would expire before the
4620/// follower taps an async-delivered link).
4621async fn bot_mint_claim(
4622    State(state): State<AppState>,
4623    headers: HeaderMap,
4624    body: axum::body::Bytes,
4625) -> Response {
4626    // 1. The endpoint is OFF unless a bot secret is configured.
4627    let bot_secret = match state.config.bot_secret.as_deref() {
4628        Some(s) => s,
4629        None => {
4630            warn!(
4631                "POST /bot/claims called but FEATHERREADER_BOT_SECRET is unset (endpoint disabled)"
4632            );
4633            return (
4634                StatusCode::SERVICE_UNAVAILABLE,
4635                "bot mint endpoint disabled (FEATHERREADER_BOT_SECRET unset)\n",
4636            )
4637                .into_response();
4638        }
4639    };
4640
4641    // 2. Constant-time bearer check on the X-Bot-Secret header.
4642    let presented = headers
4643        .get("x-bot-secret")
4644        .and_then(|v| v.to_str().ok())
4645        .unwrap_or("");
4646    if !bot_secret_matches(presented, bot_secret) {
4647        warn!("POST /bot/claims rejected: bad or missing X-Bot-Secret");
4648        return (StatusCode::UNAUTHORIZED, "bad bot secret\n").into_response();
4649    }
4650
4651    // 2b. Parse the (optional) JSON body → the follower DID/handle. An empty body
4652    // (legacy caller) parses to an all-None request; a malformed body is a 400.
4653    let req: BotClaimRequest = if body.is_empty() {
4654        BotClaimRequest::default()
4655    } else {
4656        match serde_json::from_slice(&body) {
4657            Ok(r) => r,
4658            Err(err) => {
4659                warn!(%err, "POST /bot/claims: bad JSON body");
4660                return (StatusCode::BAD_REQUEST, "bad json body\n").into_response();
4661            }
4662        }
4663    };
4664    let follower_did = req.did.as_deref().filter(|d| !d.is_empty());
4665
4666    // 3. Server-side DID idempotency (only when a DID was supplied):
4667    if let Some(did) = follower_did {
4668        // 3a. Already seated → tell the bot to post nothing.
4669        match store::has_beta_access(&state.db, did).await {
4670            Ok(true) => {
4671                info!("bot mint: DID already holds beta access; already_seated");
4672                return bot_claim_json(BotClaimResponse {
4673                    status: "already_seated",
4674                    code: String::new(),
4675                    token: String::new(),
4676                    url: String::new(),
4677                });
4678            }
4679            Ok(false) => {}
4680            Err(err) => {
4681                // Fail closed: a DB error must not fall through to a fresh mint.
4682                warn!(%err, "bot mint: has_beta_access failed");
4683                return (StatusCode::INTERNAL_SERVER_ERROR, "lookup failed\n").into_response();
4684            }
4685        }
4686        // 3b. Outstanding active claim for this DID → return the SAME code (no
4687        // second mint). This is what survives a bot-host state loss.
4688        match store::find_active_code_for_did(&state.db, did).await {
4689            Ok(Some(code)) => {
4690                info!("bot mint: existing outstanding claim for DID; returning same code");
4691                let token = sign_claim_token(&code, &state.config.cookie_secret);
4692                let url = format!("{}/claim?t={}", state.config.public_url, qenc(&token));
4693                return bot_claim_json(BotClaimResponse {
4694                    status: "existing",
4695                    code,
4696                    token,
4697                    url,
4698                });
4699            }
4700            Ok(None) => {}
4701            Err(err) => {
4702                warn!(%err, "bot mint: find_active_code_for_did failed");
4703                return (StatusCode::INTERNAL_SERVER_ERROR, "lookup failed\n").into_response();
4704            }
4705        }
4706    }
4707
4708    // 4. Cap accounting: seats already granted + outstanding unredeemed codes.
4709    //    FAIL CLOSED — a count error is a 500, not a silent mint past the cap.
4710    let granted = match store::count_beta_access(&state.db).await {
4711        Ok(n) => n,
4712        Err(err) => {
4713            warn!(%err, "bot mint: count_beta_access failed; failing closed");
4714            return (StatusCode::INTERNAL_SERVER_ERROR, "count failed\n").into_response();
4715        }
4716    };
4717    let outstanding = match store::count_active_codes(&state.db).await {
4718        Ok(n) => n,
4719        Err(err) => {
4720            warn!(%err, "bot mint: count_active_codes failed; failing closed");
4721            return (StatusCode::INTERNAL_SERVER_ERROR, "count failed\n").into_response();
4722        }
4723    };
4724    if granted + outstanding >= state.config.beta_cap {
4725        info!(
4726            granted,
4727            outstanding,
4728            cap = state.config.beta_cap,
4729            "bot mint refused: at capacity"
4730        );
4731        return (
4732            StatusCode::CONFLICT,
4733            [(header::CONTENT_TYPE, "application/json")],
4734            "{\"error\":\"full\"}\n",
4735        )
4736            .into_response();
4737    }
4738
4739    // 5. Mint with the generous claim TTL, recording the follower DID (when given)
4740    //    so a re-request for the same DID returns THIS code idempotently.
4741    let bot_did = state
4742        .config
4743        .admin_seed_dids()
4744        .first()
4745        .cloned()
4746        .unwrap_or_else(|| "did:bot:featherreader".to_string());
4747    let minted = match follower_did {
4748        Some(did) => {
4749            store::mint_code_for_did(&state.db, &bot_did, state.config.claim_ttl_secs, did).await
4750        }
4751        None => store::mint_code(&state.db, &bot_did, state.config.claim_ttl_secs).await,
4752    };
4753    let code = match minted {
4754        Ok(c) => c,
4755        // S4: the dedupe check (3b) and this mint are separate statements, so two
4756        // concurrent requests for one DID can both fall through 3b's `Ok(None)`.
4757        // The partial unique index `idx_invite_codes_intended_active` makes the
4758        // loser's INSERT fail (only one active row per intended DID), which
4759        // surfaces here as a conflict. Recover by returning the winner's existing
4760        // code (same shape as the 3b idempotent path) instead of a 500.
4761        Err(err) if follower_did.is_some() && store::is_intended_active_conflict(&err) => {
4762            match store::find_active_code_for_did(&state.db, follower_did.unwrap()).await {
4763                Ok(Some(code)) => {
4764                    info!("bot mint: lost the mint race; returning the concurrently-minted code");
4765                    let token = sign_claim_token(&code, &state.config.cookie_secret);
4766                    let url = format!("{}/claim?t={}", state.config.public_url, qenc(&token));
4767                    return bot_claim_json(BotClaimResponse {
4768                        status: "existing",
4769                        code,
4770                        token,
4771                        url,
4772                    });
4773                }
4774                // The winner's row vanished between the conflict and this lookup
4775                // (redeemed/expired/purged in the gap) — nothing to hand back.
4776                // Fail closed rather than silently mint past the just-hit guard.
4777                Ok(None) => {
4778                    warn!("bot mint: conflict but no active code found on recovery");
4779                    return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
4780                }
4781                Err(err) => {
4782                    warn!(%err, "bot mint: recovery lookup after conflict failed");
4783                    return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
4784                }
4785            }
4786        }
4787        Err(err) => {
4788            warn!(%err, "bot mint_code failed");
4789            return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
4790        }
4791    };
4792    let token = sign_claim_token(&code, &state.config.cookie_secret);
4793    let url = format!("{}/claim?t={}", state.config.public_url, qenc(&token));
4794    info!("bot minted a claim code + token");
4795
4796    bot_claim_json(BotClaimResponse {
4797        status: "minted",
4798        code,
4799        token,
4800        url,
4801    })
4802}
4803
4804/// Serialize a [`BotClaimResponse`] to a `200 application/json` response (or a
4805/// `500` if serialization somehow fails).
4806fn bot_claim_json(resp: BotClaimResponse) -> Response {
4807    match serde_json::to_string(&resp) {
4808        Ok(body) => (
4809            StatusCode::OK,
4810            [(header::CONTENT_TYPE, "application/json")],
4811            body,
4812        )
4813            .into_response(),
4814        Err(err) => {
4815            warn!(%err, "serializing bot claim response failed");
4816            (StatusCode::INTERNAL_SERVER_ERROR, "serialize failed\n").into_response()
4817        }
4818    }
4819}
4820
4821/// Constant-time equality for the bot bearer secret (avoid a timing side-channel
4822/// on the shared secret). Delegates to the same `cookie::constant_time_eq` used
4823/// by the HMAC checks so there is one comparator to audit; a length mismatch
4824/// short-circuits to `false`, which is fine — the secret length isn't sensitive.
4825fn bot_secret_matches(presented: &str, expected: &str) -> bool {
4826    cookie::constant_time_eq(presented.as_bytes(), expected.as_bytes())
4827}
4828
4829// ---------------------------------------------------------------------------
4830// Signed, short-lived invite cookie (reuses the session-cookie HMAC helper)
4831// ---------------------------------------------------------------------------
4832
4833/// Sign the reserved invite `code` into a short-lived `Set-Cookie` value. Reuses
4834/// the same HMAC-SHA256 helper as the session cookie; the payload is the code
4835/// itself (base64url) rather than an opaque sid, since the code IS the reserved
4836/// intent the callback consumes.
4837fn sign_invite(code: &str, secret: &str) -> String {
4838    cookie::sign_value(INVITE_COOKIE, code, secret, INVITE_TTL_SECS)
4839}
4840
4841/// Verify + read the reserved invite code out of the request's invite cookie
4842/// (`None` if absent, tampered, or forged). No expiry is enforced here beyond
4843/// the cookie's own `Max-Age`; the atomic `redeem_code` at the callback is the
4844/// authority on the code's live status.
4845fn invite_cookie_code(headers: &HeaderMap, secret: &str) -> Option<String> {
4846    cookie::verify_value(headers, INVITE_COOKIE, secret)
4847}
4848
4849/// Domain-separation label for the claim TOKEN's HMAC (distinct from the
4850/// `fr_invite`/`fr_session` cookie names), so a token can never be replayed as a
4851/// cookie value and vice-versa.
4852const CLAIM_TOKEN_LABEL: &str = "claim-token";
4853
4854/// Sign an invite `code` into a URL-safe claim TOKEN: `b64url(code).<sig>`
4855/// (HMAC-SHA256 over `"claim-token" || 0x00 || code`).
4856///
4857/// NOTE — the token is NOT confidential: the `b64url(code)` half is trivially
4858/// decodable by anyone, so the raw `FEATHER-…` code is effectively public in the
4859/// claim URL. The token's security is INTEGRITY + SINGLE-USE, not secrecy: the
4860/// `<sig>` HMAC means only this instance can MINT a valid token (a forged/guessed
4861/// code won't verify), the wrapped code is single-use (redeem flips
4862/// `active→redeemed`), and `/claim` is per-IP rate-limited. Wrapping keeps the
4863/// token one self-contained string needing no server-side token table; it does
4864/// NOT hide the code.
4865fn sign_claim_token(code: &str, secret: &str) -> String {
4866    cookie::sign_token(CLAIM_TOKEN_LABEL, code, secret)
4867}
4868
4869/// Verify a claim token and return the invite code it wraps (`None` on a tampered
4870/// / forged / malformed token). The code's live status (active/unexpired/seat
4871/// free) is re-checked by `preflight_code`; this only proves the token was minted
4872/// by this instance.
4873fn claim_token_code(token: &str, secret: &str) -> Option<String> {
4874    cookie::verify_token(CLAIM_TOKEN_LABEL, token, secret)
4875}
4876
4877/// Clear the invite cookie on a response (after a successful bind, or when the
4878/// reservation turned out to be stale).
4879fn clear_invite_cookie(resp: &mut Response) {
4880    set_cookie(
4881        resp,
4882        &format!("{INVITE_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
4883    );
4884}
4885
4886// ---------------------------------------------------------------------------
4887// OPML import + export
4888// ---------------------------------------------------------------------------
4889
4890/// `POST /opml` — import subscriptions from an OPML document.
4891///
4892/// Accepts either a multipart file upload (field `file`) or a pasted textarea
4893/// (field `opml`). The parsed feeds each become a `community.lexicon.rss.folder`
4894/// (for any named folders) + a `community.lexicon.rss.subscription` record in the
4895/// user's PDS via the records layer's bulk-add (`add_subscriptions_bulk`, one
4896/// `applyWrites` round-trip). Feeds are also upserted into the local cache so
4897/// they show immediately; polling is left to the background poller.
4898async fn import_opml(
4899    State(state): State<AppState>,
4900    headers: HeaderMap,
4901    mut multipart: Multipart,
4902) -> Result<Response, WebError> {
4903    let did = match current_did(&state, &headers).await {
4904        Some(d) => d,
4905        None => return Ok(Redirect::to("/login").into_response()),
4906    };
4907    let pool = &state.db;
4908
4909    // Collect the OPML text from whichever field carried it. Multipart errors
4910    // are mapped to their axum-native response so that an over-cap upload (the
4911    // `DefaultBodyLimit` on this route, see `OPML_BODY_LIMIT`) surfaces as
4912    // `413 Payload Too Large` rather than being swallowed by the blanket
4913    // `WebError` → `500` conversion.
4914    let mut opml_text = String::new();
4915    while let Some(field) = multipart.next_field().await.map_err(multipart_response)? {
4916        let name = field.name().unwrap_or("").to_string();
4917        if name == "opml" || name == "file" {
4918            let bytes = field.bytes().await.map_err(multipart_response)?;
4919            if !bytes.is_empty() {
4920                opml_text = String::from_utf8_lossy(&bytes).into_owned();
4921                if name == "file" {
4922                    break;
4923                }
4924            }
4925        }
4926    }
4927
4928    // A parse FAILURE and an empty-but-valid file are different things, and
4929    // `unwrap_or_default` collapsed them: a malformed export was reported to the
4930    // reader as "No feeds found in that OPML", which sends them looking at their
4931    // old reader for feeds that are right there in the file.
4932    let feeds =
4933        match opml::parse_opml(&opml_text) {
4934            Ok(feeds) => feeds,
4935            Err(err) => {
4936                warn!(%err, %did, "OPML import could not parse the uploaded file");
4937                return Ok(Redirect::to(&format!(
4938                "/?flash={}",
4939                qenc("That file could not be read as OPML. Export it again from your other reader?")
4940            ))
4941                .into_response());
4942            }
4943        };
4944    if feeds.is_empty() {
4945        info!(%did, "OPML import found no feeds");
4946        return Ok(
4947            Redirect::to(&format!("/?flash={}", qenc("No feeds found in that OPML")))
4948                .into_response(),
4949        );
4950    }
4951
4952    // Create any named folders first, mapping folder name → at:// URI so
4953    // subscriptions can reference them.
4954    let now = now_rfc3339();
4955    let mut folder_uris: std::collections::HashMap<String, String> =
4956        std::collections::HashMap::new();
4957    // Reuse existing folders where the name already exists.
4958    if let Ok(existing) = state.repo().list_folders_sorted(&did).await {
4959        for (rkey, folder) in existing {
4960            folder_uris
4961                .entry(folder.name.clone())
4962                .or_insert_with(|| folder_uri(&did, &rkey));
4963        }
4964    }
4965    let mut wanted_folders: Vec<String> = feeds
4966        .iter()
4967        .filter_map(|f| f.folder.clone())
4968        .filter(|n| !n.is_empty())
4969        .collect();
4970    wanted_folders.sort();
4971    wanted_folders.dedup();
4972    for name in wanted_folders {
4973        if folder_uris.contains_key(&name) {
4974            continue;
4975        }
4976        let folder = Folder::new(name.clone(), now.clone());
4977        match state.repo().add_folder(&did, &folder).await {
4978            Ok(rkey) => {
4979                folder_uris.insert(name, folder_uri(&did, &rkey));
4980            }
4981            Err(err) => warn!(%err, %did, "OPML folder create failed"),
4982        }
4983    }
4984
4985    // Build one subscription record per PUBLIC feed + upsert the local cache row.
4986    // Private/paid feeds are SKIPPED entirely (never stored, fetched, or written)
4987    // and reported back to the user — the same public-feeds-only stance as the
4988    // single-add path, so an OPML import can't leak a Substack/Patreon/podcast
4989    // token onto the public network either.
4990    // Per-DID subscription cap: an OPML import must not blow past the cap. Compute
4991    // the remaining headroom (cap − existing) once; public feeds beyond it are
4992    // TRIMMED (not imported) and reported. `<= 0` disables the cap.
4993    let sub_cap = state.config.max_subs_per_did;
4994    let mut headroom: Option<i64> = if sub_cap > 0 {
4995        let existing = store::count_subscriptions_for_did(pool, &did)
4996            .await
4997            .unwrap_or(0);
4998        Some((sub_cap - existing).max(0))
4999    } else {
5000        None
5001    };
5002    let mut trimmed_over_cap: usize = 0;
5003
5004    // Global feeds ceiling: an OPML import must not blow past the shared cache
5005    // ceiling any more than the single-add path may. Seed the remaining global
5006    // headroom (cap − current feeds) once, and only a BRAND-NEW feed URL (one
5007    // not already cached) consumes it. Existing/duplicate URLs add no row and
5008    // are always allowed. Feeds past the ceiling are TRIMMED and reported.
5009    // `<= 0` disables the ceiling.
5010    let feeds_cap = state.config.max_feeds_global;
5011    let mut global_headroom: Option<i64> = if feeds_cap > 0 {
5012        let existing = store::count_feeds(pool).await.unwrap_or(0);
5013        Some((feeds_cap - existing).max(0))
5014    } else {
5015        None
5016    };
5017    let mut trimmed_over_global: usize = 0;
5018
5019    let mut subs = Vec::with_capacity(feeds.len());
5020    let mut skipped_private: Vec<String> = Vec::new();
5021    // Imported into the PDS but not cached locally, so not pollable until the
5022    // next import touches them. Counted rather than only logged — see below.
5023    let mut uncached: usize = 0;
5024    // Entries this instance cannot store at all (an `at://` publication with
5025    // the flag off, an unsupported scheme). Counted, because the `continue`
5026    // below used to increment nothing while the privacy branch beside it
5027    // produced a label — so an OPML from a standard.site-enabled instance
5028    // imported "successfully" with entries missing and no reason given.
5029    let mut skipped_unsupported: usize = 0;
5030    for f in &feeds {
5031        // `xmlUrl` is whatever the uploaded file says, and nothing on this path
5032        // ever parsed it — the single-add path can't reach here because
5033        // `resolve_feed_url` must parse AND successfully fetch first. So
5034        // `javascript:alert(1)` and `file:///etc/passwd` were both accepted,
5035        // cached, and published as records to the user's PUBLIC repo. Note that
5036        // `classify_feed_privacy` does not catch these: both parse cleanly, and
5037        // it returns `Public` for anything unparseable by design.
5038        if !feed::is_storable_feed_url(&f.feed_url, state.config.standard_site) {
5039            info!(
5040                %did,
5041                "skipped an OPML entry whose xmlUrl is not a storable feed URL"
5042            );
5043            skipped_unsupported += 1;
5044            continue;
5045        }
5046        if let feed::FeedPrivacy::Private(reason) = feed::classify_feed_privacy(&f.feed_url) {
5047            info!(feed = %f.feed_url, %reason, %did, "skipped private/paid feed on OPML import (not stored)");
5048            // Report by title where we have one, else the (public-safe) host.
5049            let label = f
5050                .title
5051                .clone()
5052                .filter(|t| !t.trim().is_empty())
5053                .unwrap_or_else(|| private_feed_label(&f.feed_url));
5054            skipped_private.push(label);
5055            continue;
5056        }
5057
5058        // Over-cap: stop importing once headroom is exhausted (count the rest so
5059        // we can tell the user how many were dropped).
5060        if let Some(h) = headroom.as_mut() {
5061            if *h <= 0 {
5062                trimmed_over_cap += 1;
5063                continue;
5064            }
5065        }
5066
5067        // Global ceiling: a brand-new feed URL consumes global headroom. Once
5068        // it's exhausted, refuse to cache further NEW feeds (existing URLs are
5069        // free — they add no row). Checked before decrementing the per-DID
5070        // headroom so a dropped feed doesn't burn the caller's own quota.
5071        let is_new = match store::get_feed_by_url(pool, &f.feed_url).await {
5072            Ok(existing) => existing.is_none(),
5073            // On a lookup error, treat as existing (don't consume global
5074            // headroom) but still allow the upsert to proceed.
5075            Err(err) => {
5076                warn!(%err, feed = %f.feed_url, "get_feed_by_url failed during OPML global-cap check");
5077                false
5078            }
5079        };
5080        if is_new {
5081            if let Some(g) = global_headroom.as_mut() {
5082                if *g <= 0 {
5083                    trimmed_over_global += 1;
5084                    continue;
5085                }
5086                *g -= 1;
5087            }
5088        }
5089
5090        // Passed both caps: consume the per-DID headroom now that the feed is
5091        // actually being imported.
5092        if let Some(h) = headroom.as_mut() {
5093            *h -= 1;
5094        }
5095
5096        let mut sub = Subscription::new(f.feed_url.clone(), now.clone());
5097        sub.title = f.title.clone();
5098        sub.site_url = f.site_url.clone();
5099        sub.folder = f
5100            .folder
5101            .as_ref()
5102            .and_then(|name| folder_uris.get(name).cloned());
5103        subs.push(sub);
5104        // Same support ticket as the single-add path: no `feeds` row means the
5105        // poller never selects this subscription, so the import looks like it
5106        // worked and the feed silently never updates. Counted as well as logged,
5107        // because one line per feed in a 200-feed import is not something anyone
5108        // reads — the count goes to the reader.
5109        if let Err(err) = store::upsert_feed(
5110            pool,
5111            &store::NewFeed {
5112                url: f.feed_url.clone(),
5113                title: f.title.clone(),
5114                site_url: f.site_url.clone(),
5115                ..Default::default()
5116            },
5117        )
5118        .await
5119        {
5120            warn!(%err, %did, url = %f.feed_url, "OPML import could not cache a feed; \
5121                                                  it will not be polled");
5122            uncached += 1;
5123        }
5124    }
5125
5126    // **A failed PDS write is not an import.**
5127    //
5128    // The subscriptions live in the reader's repo; a local `feeds` row is just a
5129    // poller hint. This used to `warn!` and then report "Imported N feeds"
5130    // regardless, so a total failure read as a total success — and the reader
5131    // would only discover otherwise on their next visit, with an empty sidebar.
5132    let pds_written = match state.repo().add_subscriptions_bulk(&did, &subs).await {
5133        Ok(rkeys) => {
5134            info!(%did, count = rkeys.len(), skipped = skipped_private.len(), "imported OPML subscriptions to PDS (batched)");
5135            true
5136        }
5137        Err(err) => {
5138            warn!(%err, %did, "OPML PDS batch write failed (feeds cached locally)");
5139            false
5140        }
5141    };
5142    if !pds_written {
5143        return Ok(Redirect::to(&format!(
5144            "/?flash={}",
5145            qenc(
5146                "Could not save those subscriptions to your PDS, so nothing was imported. \
5147                 Try again in a moment."
5148            )
5149        ))
5150        .into_response());
5151    }
5152
5153    // Report the import count, plus any private/paid feeds skipped as unsupported.
5154    let mut flash = format!("Imported {} feeds", subs.len());
5155    if uncached > 0 {
5156        flash.push_str(&format!(
5157            ". {uncached} of them could not be cached locally and may not update until the next import."
5158        ));
5159    }
5160    if trimmed_over_cap > 0 {
5161        flash.push_str(&format!(
5162            ". {trimmed_over_cap} feed(s) not imported: your subscription limit ({sub_cap}) was reached."
5163        ));
5164    }
5165    if trimmed_over_global > 0 {
5166        flash.push_str(&format!(
5167            ". {trimmed_over_global} feed(s) not imported: this instance is at its feed capacity right now."
5168        ));
5169    }
5170    if !skipped_private.is_empty() {
5171        flash.push_str(&format!(
5172            ". {} feed(s) skipped as private/paid: {} — not supported yet (public feeds only for now).",
5173            skipped_private.len(),
5174            skipped_private.join(", ")
5175        ));
5176    }
5177    if skipped_unsupported > 0 {
5178        // By count only — the URL is whatever the file said, and unlike the
5179        // private branch there is no public-safe label to give.
5180        flash.push_str(&format!(
5181            ". {skipped_unsupported} feed(s) skipped: not a kind of feed this instance can subscribe to."
5182        ));
5183    }
5184    Ok(Redirect::to(&format!("/?flash={}", qenc(&flash))).into_response())
5185}
5186
5187/// A public-safe label for a skipped private feed when it has no title: just the
5188/// host, so we never echo the secret-bearing path/query back to the user.
5189fn private_feed_label(url: &str) -> String {
5190    url::Url::parse(url)
5191        .ok()
5192        .and_then(|u| u.host_str().map(str::to_string))
5193        .unwrap_or_else(|| "a private feed".to_string())
5194}
5195
5196/// `GET /opml/export` — export the user's subscriptions + folders as OPML.
5197async fn export_opml(
5198    State(state): State<AppState>,
5199    headers: HeaderMap,
5200) -> Result<Response, WebError> {
5201    let did = match current_did(&state, &headers).await {
5202        Some(d) => d,
5203        None => return Ok(Redirect::to("/login").into_response()),
5204    };
5205
5206    let subs = state
5207        .repo()
5208        .list_subscriptions_sorted(&did)
5209        .await
5210        .unwrap_or_default();
5211    let folders = state
5212        .repo()
5213        .list_folders_sorted(&did)
5214        .await
5215        .unwrap_or_default();
5216    // The exporter matches a subscription's `folder` at-uri against the folder's
5217    // pair key; our folder pairs are keyed by rkey, so rebuild them as at-uris.
5218    let folder_pairs: Vec<(String, Folder)> = folders
5219        .into_iter()
5220        .map(|(rkey, f)| (folder_uri(&did, &rkey), f))
5221        .collect();
5222
5223    let body = opml::to_opml(&subs, &folder_pairs);
5224    let mut resp = (StatusCode::OK, body).into_response();
5225    resp.headers_mut().insert(
5226        header::CONTENT_TYPE,
5227        "text/x-opml; charset=utf-8".parse().unwrap(),
5228    );
5229    resp.headers_mut().insert(
5230        header::CONTENT_DISPOSITION,
5231        "attachment; filename=\"featherreader-subscriptions.opml\""
5232            .parse()
5233            .unwrap(),
5234    );
5235    Ok(resp)
5236}
5237
5238// ---------------------------------------------------------------------------
5239// Signed session cookie (HMAC-SHA256, dependency-free)
5240// ---------------------------------------------------------------------------
5241
5242/// Set a `Set-Cookie` header on a response (append, so logout+redirect compose).
5243fn set_cookie(resp: &mut Response, cookie: &str) {
5244    if let Ok(value) = axum::http::HeaderValue::from_str(cookie) {
5245        resp.headers_mut()
5246            .append(axum::http::header::SET_COOKIE, value);
5247    }
5248}
5249
5250/// Whether the request came from htmx (the `HX-Request` header).
5251fn is_htmx(headers: &HeaderMap) -> bool {
5252    headers
5253        .get("HX-Request")
5254        .is_some_and(|v| v.as_bytes().eq_ignore_ascii_case(b"true"))
5255}
5256
5257/// Whether a mark-read / star request originated from the single-entry READER
5258/// (as opposed to the list view). The reader's forms tag themselves with
5259/// `X-FR-Reader: 1` via `hx-headers`; the list view's do not. This selects the
5260/// swap fragment: the reader gets an out-of-band action-bar update (its `<li>`
5261/// isn't in the DOM), the list gets the row (`entry_row.html`).
5262fn is_reader_request(headers: &HeaderMap) -> bool {
5263    headers
5264        .get("X-FR-Reader")
5265        .is_some_and(|v| v.as_bytes() == b"1")
5266}
5267
5268/// A tiny, self-contained signed-cookie layer: HMAC-SHA256 over an opaque,
5269/// server-minted **session id** (never the DID — so the cookie can't be forged
5270/// from a resolved victim DID; forging it needs the HMAC secret *and* a live
5271/// server-side session id).
5272mod cookie {
5273    use super::{HeaderMap, SESSION_COOKIE};
5274
5275    /// Sign a session id into a `Set-Cookie` header value: `fr_session=<sid>.<sig>`.
5276    pub fn sign_session(sid: &str, secret: &str) -> String {
5277        sign_value(SESSION_COOKIE, sid, secret, 2_592_000)
5278    }
5279
5280    /// Verify the request's session cookie and return the session id it carries.
5281    pub fn verify_session(headers: &HeaderMap, secret: &str) -> Option<String> {
5282        verify_value(headers, SESSION_COOKIE, secret)
5283    }
5284
5285    /// The HMAC message binding the cookie NAME to its value (`name || 0x00 ||
5286    /// value`), so a signature minted for one cookie can't verify under another —
5287    /// e.g. a value validly signed as `fr_invite` is not accepted as `fr_session`.
5288    /// The NUL separator can't appear in a cookie name, so the encoding is
5289    /// unambiguous.
5290    fn cookie_hmac_msg(name: &str, value: &str) -> Vec<u8> {
5291        let mut msg = Vec::with_capacity(name.len() + 1 + value.len());
5292        msg.extend_from_slice(name.as_bytes());
5293        msg.push(0);
5294        msg.extend_from_slice(value.as_bytes());
5295        msg
5296    }
5297
5298    /// Sign an arbitrary string `value` into a `Set-Cookie` header for `name`,
5299    /// HMAC-SHA256 over `name || 0x00 || value`: `name=<b64url(value)>.<sig>`. The
5300    /// generic form behind both the session cookie and the short-lived invite
5301    /// cookie; domain-separating by name keeps a signature valid only for the
5302    /// cookie it was minted for.
5303    pub fn sign_value(name: &str, value: &str, secret: &str, max_age_secs: i64) -> String {
5304        let sig = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(name, value));
5305        let b64 = b64url_encode(value.as_bytes());
5306        format!(
5307            "{name}={b64}.{sig}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age={max_age_secs}"
5308        )
5309    }
5310
5311    /// Verify + read a value out of the named signed cookie (`None` on absent /
5312    /// tampered / forged / cross-cookie). The generic form behind both readers.
5313    pub fn verify_value(headers: &HeaderMap, name: &str, secret: &str) -> Option<String> {
5314        let raw = cookie_value(headers, name)?;
5315        let (b64, sig) = raw.split_once('.')?;
5316        let bytes = b64url_decode(b64)?;
5317        let value = String::from_utf8(bytes).ok()?;
5318        let expected = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(name, &value));
5319        if constant_time_eq(expected.as_bytes(), sig.as_bytes()) {
5320            Some(value)
5321        } else {
5322            None
5323        }
5324    }
5325
5326    /// Sign an arbitrary `value` into an opaque, URL-safe token string
5327    /// `b64url(value).<sig>` (HMAC-SHA256 over `label || 0x00 || value`). Unlike
5328    /// [`sign_value`] this is NOT a `Set-Cookie` header — it's a bare token for a
5329    /// URL query param (the bot's claim link). `label` domain-separates it from
5330    /// the cookies so a token can't be replayed as a cookie value.
5331    pub fn sign_token(label: &str, value: &str, secret: &str) -> String {
5332        let sig = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(label, value));
5333        let b64 = b64url_encode(value.as_bytes());
5334        format!("{b64}.{sig}")
5335    }
5336
5337    /// Verify a token minted by [`sign_token`] and return the wrapped value
5338    /// (`None` on tamper / forge / malformed). Constant-time signature compare.
5339    pub fn verify_token(label: &str, token: &str, secret: &str) -> Option<String> {
5340        let (b64, sig) = token.split_once('.')?;
5341        let bytes = b64url_decode(b64)?;
5342        let value = String::from_utf8(bytes).ok()?;
5343        let expected = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(label, &value));
5344        if constant_time_eq(expected.as_bytes(), sig.as_bytes()) {
5345            Some(value)
5346        } else {
5347            None
5348        }
5349    }
5350
5351    /// Pull one cookie value out of the `Cookie` request header.
5352    fn cookie_value(headers: &HeaderMap, name: &str) -> Option<String> {
5353        let header = headers.get(axum::http::header::COOKIE)?.to_str().ok()?;
5354        for part in header.split(';') {
5355            let part = part.trim();
5356            if let Some((k, v)) = part.split_once('=') {
5357                if k == name {
5358                    return Some(v.to_string());
5359                }
5360            }
5361        }
5362        None
5363    }
5364
5365    /// Constant-time byte comparison (avoid signature-timing leaks). Public
5366    /// within the module so the bot-secret bearer check reuses the exact same
5367    /// comparator as the cookie/token HMAC checks (one implementation to audit).
5368    pub fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
5369        if a.len() != b.len() {
5370            return false;
5371        }
5372        let mut diff = 0u8;
5373        for (x, y) in a.iter().zip(b.iter()) {
5374            diff |= x ^ y;
5375        }
5376        diff == 0
5377    }
5378
5379    // -- URL-safe base64 (no padding), std-only --------------------------------
5380
5381    const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
5382
5383    fn b64url_encode(input: &[u8]) -> String {
5384        let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
5385        for chunk in input.chunks(3) {
5386            let b = [
5387                chunk[0],
5388                *chunk.get(1).unwrap_or(&0),
5389                *chunk.get(2).unwrap_or(&0),
5390            ];
5391            let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | (b[2] as u32);
5392            out.push(B64[((n >> 18) & 63) as usize] as char);
5393            out.push(B64[((n >> 12) & 63) as usize] as char);
5394            if chunk.len() > 1 {
5395                out.push(B64[((n >> 6) & 63) as usize] as char);
5396            }
5397            if chunk.len() > 2 {
5398                out.push(B64[(n & 63) as usize] as char);
5399            }
5400        }
5401        out
5402    }
5403
5404    fn b64url_decode(input: &str) -> Option<Vec<u8>> {
5405        fn val(c: u8) -> Option<u32> {
5406            match c {
5407                b'A'..=b'Z' => Some((c - b'A') as u32),
5408                b'a'..=b'z' => Some((c - b'a' + 26) as u32),
5409                b'0'..=b'9' => Some((c - b'0' + 52) as u32),
5410                b'-' => Some(62),
5411                b'_' => Some(63),
5412                _ => None,
5413            }
5414        }
5415        let bytes = input.as_bytes();
5416        let mut out = Vec::with_capacity(input.len() / 4 * 3 + 2);
5417        for chunk in bytes.chunks(4) {
5418            let mut n = 0u32;
5419            let mut valid = 0;
5420            for (i, &c) in chunk.iter().enumerate() {
5421                n |= val(c)? << (18 - 6 * i);
5422                valid += 1;
5423            }
5424            out.push((n >> 16) as u8);
5425            if valid > 2 {
5426                out.push((n >> 8) as u8);
5427            }
5428            if valid > 3 {
5429                out.push(n as u8);
5430            }
5431        }
5432        Some(out)
5433    }
5434
5435    // -- HMAC-SHA256, std-only -------------------------------------------------
5436
5437    /// HMAC-SHA256(key, msg) as lowercase hex.
5438    fn hmac_sha256_hex(key: &[u8], msg: &[u8]) -> String {
5439        const BLOCK: usize = 64;
5440        let mut k = [0u8; BLOCK];
5441        if key.len() > BLOCK {
5442            let d = sha256(key);
5443            k[..32].copy_from_slice(&d);
5444        } else {
5445            k[..key.len()].copy_from_slice(key);
5446        }
5447        let mut ipad = [0x36u8; BLOCK];
5448        let mut opad = [0x5cu8; BLOCK];
5449        for i in 0..BLOCK {
5450            ipad[i] ^= k[i];
5451            opad[i] ^= k[i];
5452        }
5453        let mut inner = Vec::with_capacity(BLOCK + msg.len());
5454        inner.extend_from_slice(&ipad);
5455        inner.extend_from_slice(msg);
5456        let inner_hash = sha256(&inner);
5457        let mut outer = Vec::with_capacity(BLOCK + 32);
5458        outer.extend_from_slice(&opad);
5459        outer.extend_from_slice(&inner_hash);
5460        let mac = sha256(&outer);
5461        let mut hex = String::with_capacity(64);
5462        for b in mac {
5463            hex.push_str(&format!("{b:02x}"));
5464        }
5465        hex
5466    }
5467
5468    /// SHA-256 (FIPS 180-4), std-only.
5469    fn sha256(data: &[u8]) -> [u8; 32] {
5470        const K: [u32; 64] = [
5471            0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4,
5472            0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe,
5473            0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f,
5474            0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7,
5475            0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc,
5476            0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b,
5477            0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116,
5478            0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
5479            0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7,
5480            0xc67178f2,
5481        ];
5482        let mut h: [u32; 8] = [
5483            0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab,
5484            0x5be0cd19,
5485        ];
5486
5487        let bit_len = (data.len() as u64) * 8;
5488        let mut msg = data.to_vec();
5489        msg.push(0x80);
5490        while msg.len() % 64 != 56 {
5491            msg.push(0);
5492        }
5493        msg.extend_from_slice(&bit_len.to_be_bytes());
5494
5495        for block in msg.chunks(64) {
5496            let mut w = [0u32; 64];
5497            for i in 0..16 {
5498                w[i] = u32::from_be_bytes([
5499                    block[i * 4],
5500                    block[i * 4 + 1],
5501                    block[i * 4 + 2],
5502                    block[i * 4 + 3],
5503                ]);
5504            }
5505            for i in 16..64 {
5506                let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3);
5507                let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10);
5508                w[i] = w[i - 16]
5509                    .wrapping_add(s0)
5510                    .wrapping_add(w[i - 7])
5511                    .wrapping_add(s1);
5512            }
5513            let mut a = h;
5514            for i in 0..64 {
5515                let s1 = a[4].rotate_right(6) ^ a[4].rotate_right(11) ^ a[4].rotate_right(25);
5516                let ch = (a[4] & a[5]) ^ ((!a[4]) & a[6]);
5517                let t1 = a[7]
5518                    .wrapping_add(s1)
5519                    .wrapping_add(ch)
5520                    .wrapping_add(K[i])
5521                    .wrapping_add(w[i]);
5522                let s0 = a[0].rotate_right(2) ^ a[0].rotate_right(13) ^ a[0].rotate_right(22);
5523                let maj = (a[0] & a[1]) ^ (a[0] & a[2]) ^ (a[1] & a[2]);
5524                let t2 = s0.wrapping_add(maj);
5525                a[7] = a[6];
5526                a[6] = a[5];
5527                a[5] = a[4];
5528                a[4] = a[3].wrapping_add(t1);
5529                a[3] = a[2];
5530                a[2] = a[1];
5531                a[1] = a[0];
5532                a[0] = t1.wrapping_add(t2);
5533            }
5534            for i in 0..8 {
5535                h[i] = h[i].wrapping_add(a[i]);
5536            }
5537        }
5538
5539        let mut out = [0u8; 32];
5540        for (i, word) in h.iter().enumerate() {
5541            out[i * 4..i * 4 + 4].copy_from_slice(&word.to_be_bytes());
5542        }
5543        out
5544    }
5545
5546    #[cfg(test)]
5547    mod tests {
5548        use super::*;
5549
5550        #[test]
5551        fn sha256_known_vector() {
5552            let d = sha256(b"abc");
5553            let hex: String = d.iter().map(|b| format!("{b:02x}")).collect();
5554            assert_eq!(
5555                hex,
5556                "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
5557            );
5558        }
5559
5560        #[test]
5561        fn hmac_known_vector() {
5562            let mac = hmac_sha256_hex(b"Jefe", b"what do ya want for nothing?");
5563            assert_eq!(
5564                mac,
5565                "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843"
5566            );
5567        }
5568
5569        #[test]
5570        fn sign_verify_round_trips() {
5571            let secret = "test-secret";
5572            let sid = "9f2c-opaque-session-id";
5573            let cookie = sign_session(sid, secret);
5574            let pair = cookie.split(';').next().unwrap().to_string();
5575            let mut headers = HeaderMap::new();
5576            headers.insert(axum::http::header::COOKIE, pair.parse().unwrap());
5577            assert_eq!(verify_session(&headers, secret).as_deref(), Some(sid));
5578            // Wrong secret → rejected (an attacker without the HMAC key can't forge).
5579            assert!(verify_session(&headers, "other-secret").is_none());
5580        }
5581
5582        #[test]
5583        fn forged_and_tampered_cookies_are_rejected() {
5584            let secret = "test-secret";
5585
5586            // 1. A fully forged cookie: attacker knows a victim's DID/sid but not
5587            //    the secret, so an arbitrary signature must not verify.
5588            let forged = format!(
5589                "{SESSION_COOKIE}={}.{}",
5590                b64url_encode(b"attacker-chosen-sid"),
5591                "deadbeef".repeat(8) // 64 hex chars, wrong sig
5592            );
5593            let mut headers = HeaderMap::new();
5594            headers.insert(axum::http::header::COOKIE, forged.parse().unwrap());
5595            assert!(verify_session(&headers, secret).is_none());
5596
5597            // 2. A tampered cookie: take a VALID cookie and mutate the sid while
5598            //    keeping the original signature — must not verify.
5599            let cookie = sign_session("real-sid", secret);
5600            let pair = cookie.split(';').next().unwrap();
5601            let (_b64, sig) = pair.split_once('=').unwrap().1.split_once('.').unwrap();
5602            let tampered = format!(
5603                "{SESSION_COOKIE}={}.{}",
5604                b64url_encode(b"different-sid"),
5605                sig
5606            );
5607            let mut headers2 = HeaderMap::new();
5608            headers2.insert(axum::http::header::COOKIE, tampered.parse().unwrap());
5609            assert!(verify_session(&headers2, secret).is_none());
5610        }
5611
5612        #[test]
5613        fn b64url_round_trips() {
5614            for s in ["did:plc:abc", "", "a", "ab", "abc", "abcd"] {
5615                let enc = b64url_encode(s.as_bytes());
5616                assert_eq!(b64url_decode(&enc).unwrap(), s.as_bytes());
5617            }
5618        }
5619    }
5620}
5621
5622// ---------------------------------------------------------------------------
5623// Small store helpers local to the web layer
5624// ---------------------------------------------------------------------------
5625
5626/// Fetch a single cached entry by id — SCOPED to `did`'s subscriptions.
5627///
5628/// Returns `None` (→ 404 at the handler) if the entry does not exist OR if
5629/// `did` does not subscribe to its feed. This is the per-DID read gate for the
5630/// `GET /entries/:id` reader and the htmx row rebuild: the shared cache is
5631/// deduped by URL, but no DID can read another DID's cached article.
5632///
5633/// **The only `SELECT e.*` left, and deliberately so.** This is the one surface
5634/// that renders `content_html`, and it fetches exactly one row. The list views
5635/// go through [`store::list_entries`], which is both paged and body-free — see
5636/// [`store::EntryListRow`] for why they had to stop sharing this projection.
5637async fn get_entry_by_id(
5638    pool: &store::Pool,
5639    did: &str,
5640    id: i64,
5641) -> anyhow::Result<Option<store::Entry>> {
5642    let entry = sqlx::query_as::<_, store::Entry>(
5643        r#"
5644        SELECT e.* FROM entries e
5645        WHERE e.id = ?2
5646          AND EXISTS (
5647              SELECT 1 FROM sub_ref sr
5648              WHERE sr.did = ?1 AND sr.feed_id = e.feed_id
5649          )
5650        "#,
5651    )
5652    .bind(did)
5653    .bind(id)
5654    .fetch_optional(pool)
5655    .await?;
5656    Ok(entry)
5657}
5658
5659/// Whether `entry_id` is marked read for `did` (absent state row = unread).
5660async fn entry_is_read(pool: &store::Pool, did: &str, entry_id: i64) -> anyhow::Result<bool> {
5661    let read: Option<bool> =
5662        sqlx::query_scalar("SELECT read FROM entry_state WHERE did = ?1 AND entry_id = ?2")
5663            .bind(did)
5664            .bind(entry_id)
5665            .fetch_optional(pool)
5666            .await?
5667            .flatten();
5668    Ok(read.unwrap_or(false))
5669}
5670
5671/// Whether `entry_id` is starred for `did` (absent state row = not starred).
5672async fn entry_is_starred(pool: &store::Pool, did: &str, entry_id: i64) -> anyhow::Result<bool> {
5673    let starred: Option<bool> =
5674        sqlx::query_scalar("SELECT starred FROM entry_state WHERE did = ?1 AND entry_id = ?2")
5675            .bind(did)
5676            .bind(entry_id)
5677            .fetch_optional(pool)
5678            .await?
5679            .flatten();
5680    Ok(starred.unwrap_or(false))
5681}
5682
5683/// Feed display title for one entry's feed id (via a single lookup).
5684async fn feed_title_by_entry(pool: &store::Pool, feed_id: i64) -> String {
5685    match sqlx::query_as::<_, store::Feed>("SELECT * FROM feeds WHERE id = ?1")
5686        .bind(feed_id)
5687        .fetch_optional(pool)
5688        .await
5689    {
5690        Ok(Some(f)) => display_title(f.title.as_deref(), &f.url),
5691        _ => String::new(),
5692    }
5693}
5694
5695/// Rebuild an [`EntryRow`] for an htmx swap after a read/star toggle. `read` may
5696/// be forced (mark-read path) or looked up (`None` — star path).
5697async fn build_entry_row(
5698    pool: &store::Pool,
5699    did: &str,
5700    id: i64,
5701    read: Option<bool>,
5702) -> anyhow::Result<Option<EntryRow>> {
5703    let entry = match get_entry_by_id(pool, did, id).await? {
5704        Some(e) => e,
5705        None => return Ok(None),
5706    };
5707    let read = match read {
5708        Some(r) => r,
5709        None => entry_is_read(pool, did, id).await?,
5710    };
5711    let starred = entry_is_starred(pool, did, id).await?;
5712    Ok(Some(EntryRow {
5713        id: entry.id,
5714        title: entry
5715            .title
5716            .clone()
5717            .filter(|t| !t.trim().is_empty())
5718            .unwrap_or_else(|| "(untitled)".to_string()),
5719        feed_title: feed_title_by_entry(pool, entry.feed_id).await,
5720        published: display_date(entry.published.as_deref()),
5721        read,
5722        starred,
5723        link: SafeLink::entry(id, ""),
5724        cached: true,
5725        rkey: String::new(),
5726    }))
5727}
5728
5729/// RFC3339 "now" (UTC) — shared by handlers that stamp/compare timestamps.
5730fn now_rfc3339() -> String {
5731    chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
5732}
5733
5734#[cfg(test)]
5735mod tests {
5736    use super::*;
5737
5738    #[test]
5739    fn qenc_encodes_reserved() {
5740        assert_eq!(qenc("a b"), "a%20b");
5741        assert_eq!(
5742            qenc("https://example.com/feed.xml"),
5743            "https%3A%2F%2Fexample.com%2Ffeed.xml"
5744        );
5745        assert_eq!(
5746            qenc("at://did:plc:x/c/r"),
5747            "at%3A%2F%2Fdid%3Aplc%3Ax%2Fc%2Fr"
5748        );
5749        // Unreserved chars pass through untouched.
5750        assert_eq!(qenc("A-Za-z0-9-_.~"), "A-Za-z0-9-_.~");
5751    }
5752
5753    #[test]
5754    fn folder_uri_shape() {
5755        assert_eq!(
5756            folder_uri("did:plc:abc", "3kfolder"),
5757            "at://did:plc:abc/community.lexicon.rss.folder/3kfolder"
5758        );
5759    }
5760
5761    // -- public-feeds-only: private/paid feeds are refused --------------------
5762
5763    #[test]
5764    fn private_feeds_are_classified_private_across_providers() {
5765        // The add + OPML paths both gate on this classifier; assert it flags a
5766        // spread of paid providers (newsletters + private podcasts) and the
5767        // generic credential-in-URL shapes.
5768        for url in [
5769            "https://author.substack.com/feed/private/deadbeefcafe1234",
5770            "https://www.patreon.com/rss/author?auth=Zm9vYmFyc2VjcmV0dG9rZW4",
5771            "https://blog.ghost.io/rss/?uuid=1f2e3d4c-5b6a-7089-90ab-cdef01234567",
5772            "https://feeds.supportingcast.fm/show/abcdef0123456789abcdef01",
5773            "https://example.com/feed?token=Zm9vYmFyc2VjcmV0",
5774            "https://user:pass@example.com/feed",
5775        ] {
5776            assert!(
5777                feed::classify_feed_privacy(url).is_private(),
5778                "expected private: {url}"
5779            );
5780        }
5781    }
5782
5783    #[test]
5784    fn public_feeds_stay_public() {
5785        for url in [
5786            "https://author.substack.com/feed",
5787            "https://wordpress.example.com/feed/",
5788            "https://example.com/rss.xml",
5789            "https://example.org/atom.xml",
5790            // YouTube channel/playlist RSS is fully public — must not false-block.
5791            "https://www.youtube.com/feeds/videos.xml?channel_id=UC-lHJZR3Gqxm24_Vd_AJ5Yw",
5792            "https://www.youtube.com/feeds/videos.xml?playlist_id=PLFgquLnL59alCl_2TQvOiD5Vgm1",
5793        ] {
5794            assert!(
5795                !feed::classify_feed_privacy(url).is_private(),
5796                "expected public: {url}"
5797            );
5798        }
5799    }
5800
5801    #[test]
5802    fn private_feed_label_is_public_safe_host_only() {
5803        // The OPML skip report must never echo the secret path/query, only the host.
5804        let label =
5805            private_feed_label("https://author.substack.com/feed/private/deadbeefcafe1234token");
5806        assert_eq!(label, "author.substack.com");
5807        assert!(!label.contains("deadbeefcafe1234token"));
5808        assert!(!label.contains("/private/"));
5809        // An unparseable URL degrades to a generic label.
5810        assert_eq!(private_feed_label("not a url"), "a private feed");
5811    }
5812
5813    #[test]
5814    fn refusal_message_promises_nothing_stored() {
5815        assert!(PRIVATE_FEED_REFUSAL.contains("not saved or sent anywhere"));
5816        assert!(PRIVATE_FEED_REFUSAL.contains("public feeds"));
5817    }
5818
5819    #[test]
5820    fn scope_query_preserves_context() {
5821        let q = EntryQuery {
5822            feed: Some("https://example.com/feed.xml".to_string()),
5823            folder: None,
5824            view: Some("all".to_string()),
5825        };
5826        let s = scope_query(&q);
5827        assert!(s.contains("feed=https%3A%2F%2Fexample.com%2Ffeed.xml"));
5828        assert!(s.contains("view=all"));
5829
5830        // Default view is omitted.
5831        let q2 = EntryQuery {
5832            feed: None,
5833            folder: None,
5834            view: Some("unread".to_string()),
5835        };
5836        assert_eq!(scope_query(&q2), "");
5837    }
5838
5839    // -- closed-beta invite gate + rate-limit + cache-control ------------------
5840
5841    use axum::body::Body;
5842    use axum::http::Request;
5843    use tower::ServiceExt; // for `oneshot`
5844
5845    /// Build an [`AppState`] over a fresh in-memory DB, seeding the given admin
5846    /// DIDs (via ALLOWED_DIDS → ensure_seed) and a fixed cookie secret so tests
5847    /// can forge matching cookies.
5848    async fn test_state(allowed: &[&str]) -> AppState {
5849        let db = store::init_url("sqlite::memory:").await.unwrap();
5850        let dids: Vec<String> = allowed.iter().map(|s| s.to_string()).collect();
5851        store::ensure_seed(&db, &dids).await.unwrap();
5852        let config = Config {
5853            allowed_dids: dids,
5854            cookie_secret: "test-cookie-secret-000".to_string(),
5855            beta_cap: 3,
5856            ..Config::default()
5857        };
5858        AppState::new(config, db).unwrap()
5859    }
5860
5861    /// A `Cookie` header carrying a valid signed session for `sid` (the sid is
5862    /// looked up in the registry, so create the session first).
5863    fn session_cookie(state: &AppState, did: &str, handle: Option<&str>) -> String {
5864        let sid = state.sessions.create(Session {
5865            did: did.to_string(),
5866            handle: handle.map(str::to_string),
5867        });
5868        let sc = cookie::sign_session(&sid, &state.config.cookie_secret);
5869        sc.split(';').next().unwrap().to_string()
5870    }
5871
5872    /// The bucket map is bounded by COUNT, not only by idle time. An hour is a
5873    /// long time to accept distinct source IPs on two unauthenticated guarded
5874    /// routes.
5875    #[test]
5876    fn the_rate_limit_map_is_bounded() {
5877        let rl = RateLimiter::shared();
5878        let now = Instant::now();
5879        for i in 0..(MAX_RATE_BUCKETS + 2_000) {
5880            // Distinct IPv6 addresses, all "seen" at increasing times so the LRU
5881            // ordering below is well-defined.
5882            let ip: IpAddr = format!("2001:db8::{i:x}").parse().unwrap();
5883            rl.check_at(ip, now + Duration::from_millis(i as u64));
5884        }
5885        let len = rl.inner.lock().unwrap().buckets.len();
5886        assert!(
5887            len <= MAX_RATE_BUCKETS,
5888            "the rate-limit map grew to {len}, past its {MAX_RATE_BUCKETS} cap"
5889        );
5890    }
5891
5892    /// Eviction must not hand a throttled attacker a fresh burst.
5893    ///
5894    /// The bound is LRU, so the one bucket an attacker can never evict is their
5895    /// own — it is the most recently touched thing in the map. If this inverted,
5896    /// the size cap would become a rate-limit bypass: spray addresses until the
5897    /// map overflows, then resume.
5898    #[test]
5899    fn flooding_the_map_does_not_reset_the_flooders_own_bucket() {
5900        let rl = RateLimiter::shared();
5901        let base = Instant::now();
5902        let attacker: IpAddr = "203.0.113.7".parse().unwrap();
5903        // Nanosecond steps: enough to keep the LRU ordering strictly increasing,
5904        // far too little for `RATE_REFILL_PER_SEC` to hand back a token. A
5905        // millisecond step made the whole flood take a second, and the refill —
5906        // working correctly — then looked exactly like an eviction bypass.
5907        let at = |n: u64| base + Duration::from_nanos(n);
5908
5909        // Spend the burst. `RATE_BURST` allowed, then refused.
5910        for i in 0..(RATE_BURST as u64) {
5911            assert!(rl.check_at(attacker, at(i)));
5912        }
5913        assert!(
5914            !rl.check_at(attacker, at(RATE_BURST as u64)),
5915            "burst was not exhausted; the rest of this test proves nothing"
5916        );
5917
5918        // Now overflow the map from other addresses, interleaving the attacker
5919        // so their bucket stays hot — the realistic shape of the attack.
5920        for i in 0..(MAX_RATE_BUCKETS + 2_000) {
5921            let t = at(100 + i as u64 * 2);
5922            let ip: IpAddr = format!("2001:db8:1::{i:x}").parse().unwrap();
5923            rl.check_at(ip, t);
5924            assert!(
5925                !rl.check_at(attacker, t),
5926                "the attacker got a token back after evictions at i={i}"
5927            );
5928        }
5929    }
5930
5931    /// The idle sweep is amortised, not per-request. It used to be an O(n) scan
5932    /// of the whole map on every guarded request, on one shared core.
5933    #[test]
5934    fn the_idle_sweep_does_not_run_on_every_request() {
5935        let rl = RateLimiter::shared();
5936        let start = Instant::now();
5937        let a: IpAddr = "198.51.100.1".parse().unwrap();
5938        let b: IpAddr = "198.51.100.2".parse().unwrap();
5939
5940        rl.check_at(a, start);
5941        // `b` arrives an hour later: `a` is now idle past `RATE_IDLE_EVICT`, but
5942        // the sweep interval has elapsed too, so this request does sweep it.
5943        rl.check_at(b, start + RATE_IDLE_EVICT + Duration::from_secs(1));
5944        assert!(
5945            !rl.inner.lock().unwrap().buckets.contains_key(&a),
5946            "an idle bucket survived a sweep that was due"
5947        );
5948
5949        // A second request moments later must NOT re-sweep — `b` is still there,
5950        // and the recorded sweep time must not have moved.
5951        let before = rl.inner.lock().unwrap().last_sweep;
5952        rl.check_at(b, start + RATE_IDLE_EVICT + Duration::from_secs(2));
5953        assert_eq!(
5954            rl.inner.lock().unwrap().last_sweep,
5955            before,
5956            "the sweep ran again within the interval"
5957        );
5958    }
5959
5960    #[test]
5961    fn rate_limited_paths_match_expected() {
5962        use axum::http::Method;
5963        assert!(is_rate_limited_path("/login", &Method::GET));
5964        assert!(is_rate_limited_path("/login", &Method::POST));
5965        assert!(is_rate_limited_path("/beta/redeem", &Method::POST));
5966        assert!(is_rate_limited_path("/subscriptions", &Method::POST));
5967        assert!(is_rate_limited_path("/opml", &Method::POST));
5968        assert!(is_rate_limited_path("/read-all", &Method::POST));
5969        assert!(is_rate_limited_path("/admin/invites", &Method::POST));
5970        assert!(is_rate_limited_path("/entries/42/read", &Method::POST));
5971        assert!(is_rate_limited_path("/entries/42/star", &Method::POST));
5972        // Read-only navigation is NOT limited.
5973        assert!(!is_rate_limited_path("/", &Method::GET));
5974        assert!(!is_rate_limited_path("/about", &Method::GET));
5975        assert!(!is_rate_limited_path("/entries/42", &Method::GET));
5976        assert!(!is_rate_limited_path("/login", &Method::HEAD));
5977    }
5978
5979    #[test]
5980    fn rate_limiter_allows_burst_then_429s() {
5981        let rl = RateLimiter::shared();
5982        let ip: IpAddr = "203.0.113.7".parse().unwrap();
5983        // The full burst passes.
5984        for _ in 0..(RATE_BURST as usize) {
5985            assert!(rl.check(ip));
5986        }
5987        // The next one (no time elapsed → no refill) is rejected.
5988        assert!(!rl.check(ip));
5989        // A different IP has its own bucket.
5990        let ip2: IpAddr = "203.0.113.8".parse().unwrap();
5991        assert!(rl.check(ip2));
5992    }
5993
5994    #[test]
5995    fn client_ip_ignores_spoofed_xff_without_trusted_header() {
5996        // With NO trusted header configured, a client-supplied X-Forwarded-For
5997        // must be ignored entirely — the limiter keys on the real socket peer,
5998        // so an attacker can't mint a fresh bucket per forged XFF value.
5999        let mut h = HeaderMap::new();
6000        h.insert("x-forwarded-for", "198.51.100.9, 10.0.0.1".parse().unwrap());
6001        let sock: SocketAddr = "203.0.113.55:1234".parse().unwrap();
6002        assert_eq!(
6003            client_ip(&h, Some(&sock), None),
6004            Some("203.0.113.55".parse().unwrap()),
6005            "spoofed XFF must not override the socket peer"
6006        );
6007    }
6008
6009    #[test]
6010    fn client_ip_uses_trusted_header_last_hop() {
6011        // With a trusted proxy header configured, the client IP comes from THAT
6012        // header (the proxy overwrites any client copy). On a comma list we take
6013        // the RIGHT-most hop — the one the trusted proxy appended — so a
6014        // client-forged left-most value is ignored.
6015        let sock: SocketAddr = "10.0.0.1:1234".parse().unwrap();
6016
6017        let mut h = HeaderMap::new();
6018        h.insert("fly-client-ip", "198.51.100.9".parse().unwrap());
6019        assert_eq!(
6020            client_ip(&h, Some(&sock), Some("fly-client-ip")),
6021            Some("198.51.100.9".parse().unwrap())
6022        );
6023
6024        // Attacker prepends a forged hop; the trusted proxy appends the real one.
6025        let mut h2 = HeaderMap::new();
6026        h2.insert("x-forwarded-for", "1.2.3.4, 198.51.100.9".parse().unwrap());
6027        assert_eq!(
6028            client_ip(&h2, Some(&sock), Some("x-forwarded-for")),
6029            Some("198.51.100.9".parse().unwrap()),
6030            "must take the right-most (trusted) hop, not the forged left-most"
6031        );
6032
6033        // Trusted header absent → fall back to the socket peer.
6034        let h3 = HeaderMap::new();
6035        assert_eq!(
6036            client_ip(&h3, Some(&sock), Some("fly-client-ip")),
6037            Some("10.0.0.1".parse().unwrap())
6038        );
6039    }
6040
6041    #[test]
6042    fn invite_cookie_round_trips_and_rejects_tamper() {
6043        let secret = "test-cookie-secret-000";
6044        let sc = sign_invite("FEATHER-ABCDWXYZ", secret);
6045        let pair = sc.split(';').next().unwrap();
6046        let mut h = HeaderMap::new();
6047        h.insert(header::COOKIE, pair.parse().unwrap());
6048        assert_eq!(
6049            invite_cookie_code(&h, secret).as_deref(),
6050            Some("FEATHER-ABCDWXYZ")
6051        );
6052        // Wrong secret → rejected.
6053        assert!(invite_cookie_code(&h, "other").is_none());
6054    }
6055
6056    #[tokio::test]
6057    async fn preflight_valid_expired_and_full() {
6058        let state = test_state(&["did:plc:admin"]).await;
6059        // A minted, active code preflights OK.
6060        let code = store::mint_code(&state.db, "did:plc:admin", 3600)
6061            .await
6062            .unwrap();
6063        assert!(preflight_code(&state, &code).await.is_ok());
6064
6065        // A code whose expiry is in the past preflights as Expired. (mint_code
6066        // clamps negative ttl to 0, so back-date the row directly for a
6067        // deterministic past expiry.)
6068        let expired = store::mint_code(&state.db, "did:plc:admin", 3600)
6069            .await
6070            .unwrap();
6071        sqlx::query("UPDATE invite_codes SET expires_at = ?1 WHERE code = ?2")
6072            .bind(chrono::Utc::now().timestamp() - 3600)
6073            .bind(&expired)
6074            .execute(&state.db)
6075            .await
6076            .unwrap();
6077        assert_eq!(
6078            preflight_code(&state, &expired).await,
6079            Err(store::RedeemError::Expired)
6080        );
6081
6082        // Unknown code → NotFound.
6083        assert_eq!(
6084            preflight_code(&state, "FEATHER-NOPENOPE").await,
6085            Err(store::RedeemError::NotFound)
6086        );
6087
6088        // Fill to cap (cap=3; the admin seed already took 1 seat) then preflight
6089        // must report CapacityFull.
6090        store::grant_access(&state.db, "did:plc:b", None, "admin", None)
6091            .await
6092            .unwrap();
6093        store::grant_access(&state.db, "did:plc:c", None, "admin", None)
6094            .await
6095            .unwrap();
6096        assert_eq!(store::count_beta_access(&state.db).await.unwrap(), 3);
6097        assert_eq!(
6098            preflight_code(&state, &code).await,
6099            Err(store::RedeemError::CapacityFull)
6100        );
6101    }
6102
6103    // -- Bot claim link + shared-secret mint ---------------------------------
6104
6105    /// A test state with a configured bot secret (so `/bot/claims` is live).
6106    async fn bot_state(bot_secret: &str) -> AppState {
6107        let db = store::init_url("sqlite::memory:").await.unwrap();
6108        store::ensure_seed(&db, &["did:plc:admin".to_string()])
6109            .await
6110            .unwrap();
6111        let config = Config {
6112            allowed_dids: vec!["did:plc:admin".to_string()],
6113            cookie_secret: "test-cookie-secret-000".to_string(),
6114            beta_cap: 3,
6115            bot_secret: Some(bot_secret.to_string()),
6116            public_url: "https://feather-reader.com".to_string(),
6117            ..Config::default()
6118        };
6119        AppState::new(config, db).unwrap()
6120    }
6121
6122    #[test]
6123    fn claim_token_round_trips_and_rejects_tamper() {
6124        let secret = "test-cookie-secret-000";
6125        let token = sign_claim_token("FEATHER-ABCDWXYZ", secret);
6126        // No cookie framing — a bare URL-safe token.
6127        assert!(!token.contains(';'));
6128        assert_eq!(
6129            claim_token_code(&token, secret).as_deref(),
6130            Some("FEATHER-ABCDWXYZ")
6131        );
6132        // Wrong secret → rejected.
6133        assert!(claim_token_code(&token, "other").is_none());
6134        // Tampered token → rejected.
6135        let mut bad = token.clone();
6136        bad.push('x');
6137        assert!(claim_token_code(&bad, secret).is_none());
6138        // The token is NOT confidential: it is `b64url(code).<sig>`, so the code is
6139        // only base64-obscured (not verbatim, but TRIVIALLY decodable — anyone can
6140        // recover it WITHOUT the secret). The security is single-use + HMAC
6141        // integrity + rate-limit, not secrecy of the code. Assert the code half is
6142        // publicly decodable (a plain base64url decode, no secret involved).
6143        let (b64, _sig) = token.split_once('.').expect("token is b64.sig");
6144        assert_eq!(
6145            test_b64url_decode(b64).as_deref(),
6146            Some("FEATHER-ABCDWXYZ".as_bytes()),
6147            "the code half of the token is plain base64url, decodable by anyone"
6148        );
6149    }
6150
6151    /// Minimal URL-safe base64 (no padding) decoder for the test above, proving the
6152    /// claim token's code half needs NO secret to recover (it is not confidential).
6153    fn test_b64url_decode(input: &str) -> Option<Vec<u8>> {
6154        fn val(c: u8) -> Option<u32> {
6155            match c {
6156                b'A'..=b'Z' => Some((c - b'A') as u32),
6157                b'a'..=b'z' => Some((c - b'a' + 26) as u32),
6158                b'0'..=b'9' => Some((c - b'0' + 52) as u32),
6159                b'-' => Some(62),
6160                b'_' => Some(63),
6161                _ => None,
6162            }
6163        }
6164        let mut out = Vec::with_capacity(input.len() / 4 * 3);
6165        for chunk in input.as_bytes().chunks(4) {
6166            let mut n = 0u32;
6167            let mut bits = 0;
6168            for &c in chunk {
6169                n = (n << 6) | val(c)?;
6170                bits += 6;
6171            }
6172            let bytes = bits / 8;
6173            n <<= 24 - bits;
6174            for i in 0..bytes {
6175                out.push((n >> (16 - i * 8)) as u8);
6176            }
6177        }
6178        Some(out)
6179    }
6180
6181    #[tokio::test]
6182    async fn bot_mint_then_claim_grants_a_seat() {
6183        let state = bot_state("bot-secret-abcdef").await;
6184        let app = router(state.clone());
6185
6186        // 1. Mint a claim via the shared-secret endpoint.
6187        let resp = app
6188            .clone()
6189            .oneshot(
6190                Request::builder()
6191                    .method("POST")
6192                    .uri("/bot/claims")
6193                    .header("x-bot-secret", "bot-secret-abcdef")
6194                    .body(Body::empty())
6195                    .unwrap(),
6196            )
6197            .await
6198            .unwrap();
6199        assert_eq!(resp.status(), StatusCode::OK);
6200        let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
6201            .await
6202            .unwrap();
6203        let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
6204        let token = json["token"].as_str().unwrap().to_string();
6205        let url = json["url"].as_str().unwrap();
6206        assert!(url.starts_with("https://feather-reader.com/claim?t="));
6207        // The raw code is returned for the bot's records but not embedded in url.
6208        assert!(json["code"].as_str().unwrap().starts_with("FEATHER-"));
6209        assert!(!url.contains("FEATHER-"));
6210
6211        // 2. Follow the claim link → reserves the invite cookie + redirects to /login.
6212        let resp = app
6213            .clone()
6214            .oneshot(
6215                Request::builder()
6216                    .method("GET")
6217                    .uri(format!("/claim?t={}", qenc(&token)))
6218                    .body(Body::empty())
6219                    .unwrap(),
6220            )
6221            .await
6222            .unwrap();
6223        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
6224        assert_eq!(resp.headers().get(header::LOCATION).unwrap(), "/login");
6225        let set_cookie = resp
6226            .headers()
6227            .get(header::SET_COOKIE)
6228            .unwrap()
6229            .to_str()
6230            .unwrap();
6231        assert!(set_cookie.starts_with(INVITE_COOKIE), "{set_cookie}");
6232
6233        // 3. The reserved cookie carries the same code the token wrapped, and
6234        //    redeeming it (the callback's machinery) grants a seat.
6235        let code = claim_token_code(&token, &state.config.cookie_secret).unwrap();
6236        let out = store::redeem_code(
6237            &state.db,
6238            &code,
6239            "did:plc:follower",
6240            None,
6241            state.config.beta_cap,
6242        )
6243        .await
6244        .unwrap();
6245        assert_eq!(out, Ok(()));
6246        assert!(store::has_beta_access(&state.db, "did:plc:follower")
6247            .await
6248            .unwrap());
6249    }
6250
6251    #[tokio::test]
6252    async fn claim_with_invalid_token_bounces() {
6253        let state = bot_state("bot-secret-abcdef").await;
6254        let app = router(state);
6255        let resp = app
6256            .oneshot(
6257                Request::builder()
6258                    .method("GET")
6259                    .uri("/claim?t=not-a-real-token")
6260                    .body(Body::empty())
6261                    .unwrap(),
6262            )
6263            .await
6264            .unwrap();
6265        // Renders the invite page (200), NOT a redirect to /login.
6266        assert_eq!(resp.status(), StatusCode::OK);
6267    }
6268
6269    #[tokio::test]
6270    async fn claim_with_used_token_is_refused() {
6271        let state = bot_state("bot-secret-abcdef").await;
6272        // Mint a code + wrap it, then redeem it out from under the token.
6273        let code = store::mint_code(&state.db, "did:plc:admin", 3600)
6274            .await
6275            .unwrap();
6276        let token = sign_claim_token(&code, &state.config.cookie_secret);
6277        store::redeem_code(
6278            &state.db,
6279            &code,
6280            "did:plc:someone",
6281            None,
6282            state.config.beta_cap,
6283        )
6284        .await
6285        .unwrap()
6286        .unwrap();
6287        let app = router(state);
6288        let resp = app
6289            .oneshot(
6290                Request::builder()
6291                    .method("GET")
6292                    .uri(format!("/claim?t={}", qenc(&token)))
6293                    .body(Body::empty())
6294                    .unwrap(),
6295            )
6296            .await
6297            .unwrap();
6298        // A used code → the invite page (AlreadyRedeemed), not a fresh reservation.
6299        assert_eq!(resp.status(), StatusCode::OK);
6300        assert!(resp.headers().get(header::SET_COOKIE).is_none());
6301    }
6302
6303    #[tokio::test]
6304    async fn bot_claims_rejects_bad_and_missing_secret() {
6305        let state = bot_state("bot-secret-abcdef").await;
6306        let app = router(state);
6307        // Wrong secret.
6308        let resp = app
6309            .clone()
6310            .oneshot(
6311                Request::builder()
6312                    .method("POST")
6313                    .uri("/bot/claims")
6314                    .header("x-bot-secret", "wrong")
6315                    .body(Body::empty())
6316                    .unwrap(),
6317            )
6318            .await
6319            .unwrap();
6320        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
6321        // Missing secret.
6322        let resp = app
6323            .oneshot(
6324                Request::builder()
6325                    .method("POST")
6326                    .uri("/bot/claims")
6327                    .body(Body::empty())
6328                    .unwrap(),
6329            )
6330            .await
6331            .unwrap();
6332        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
6333    }
6334
6335    #[tokio::test]
6336    async fn bot_claims_disabled_when_secret_unset() {
6337        // test_state configures NO bot secret → the endpoint is off (503).
6338        let state = test_state(&["did:plc:admin"]).await;
6339        let app = router(state);
6340        let resp = app
6341            .oneshot(
6342                Request::builder()
6343                    .method("POST")
6344                    .uri("/bot/claims")
6345                    .header("x-bot-secret", "anything")
6346                    .body(Body::empty())
6347                    .unwrap(),
6348            )
6349            .await
6350            .unwrap();
6351        assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
6352    }
6353
6354    #[tokio::test]
6355    async fn bot_claims_refuses_at_capacity() {
6356        let state = bot_state("bot-secret-abcdef").await;
6357        // cap=3, admin seed took 1 seat. Grant 2 more to fill it.
6358        store::grant_access(&state.db, "did:plc:b", None, "admin", None)
6359            .await
6360            .unwrap();
6361        store::grant_access(&state.db, "did:plc:c", None, "admin", None)
6362            .await
6363            .unwrap();
6364        assert_eq!(store::count_beta_access(&state.db).await.unwrap(), 3);
6365        let app = router(state);
6366        let resp = app
6367            .oneshot(
6368                Request::builder()
6369                    .method("POST")
6370                    .uri("/bot/claims")
6371                    .header("x-bot-secret", "bot-secret-abcdef")
6372                    .body(Body::empty())
6373                    .unwrap(),
6374            )
6375            .await
6376            .unwrap();
6377        assert_eq!(resp.status(), StatusCode::CONFLICT);
6378        let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
6379            .await
6380            .unwrap();
6381        assert!(String::from_utf8_lossy(&bytes).contains("full"));
6382    }
6383
6384    #[tokio::test]
6385    async fn bot_claims_counts_outstanding_codes_against_cap() {
6386        let state = bot_state("bot-secret-abcdef").await;
6387        // cap=3, admin seed = 1 seat. Two outstanding active codes = 3 committed.
6388        store::mint_code(&state.db, "did:plc:admin", 3600)
6389            .await
6390            .unwrap();
6391        store::mint_code(&state.db, "did:plc:admin", 3600)
6392            .await
6393            .unwrap();
6394        let app = router(state);
6395        let resp = app
6396            .oneshot(
6397                Request::builder()
6398                    .method("POST")
6399                    .uri("/bot/claims")
6400                    .header("x-bot-secret", "bot-secret-abcdef")
6401                    .body(Body::empty())
6402                    .unwrap(),
6403            )
6404            .await
6405            .unwrap();
6406        // 1 seat + 2 outstanding >= cap 3 → refused even though only 1 real seat used.
6407        assert_eq!(resp.status(), StatusCode::CONFLICT);
6408    }
6409
6410    /// POST /bot/claims with a JSON body carrying the follower DID.
6411    async fn post_bot_claim_for(
6412        app: &axum::Router,
6413        secret: &str,
6414        did: &str,
6415    ) -> (StatusCode, serde_json::Value) {
6416        let resp = app
6417            .clone()
6418            .oneshot(
6419                Request::builder()
6420                    .method("POST")
6421                    .uri("/bot/claims")
6422                    .header("x-bot-secret", secret)
6423                    .header("content-type", "application/json")
6424                    .body(Body::from(format!(
6425                        "{{\"did\":\"{did}\",\"handle\":\"who.test\"}}"
6426                    )))
6427                    .unwrap(),
6428            )
6429            .await
6430            .unwrap();
6431        let status = resp.status();
6432        let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
6433            .await
6434            .unwrap();
6435        let json = if bytes.is_empty() {
6436            serde_json::Value::Null
6437        } else {
6438            serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null)
6439        };
6440        (status, json)
6441    }
6442
6443    #[tokio::test]
6444    async fn bot_claims_returns_already_seated_for_a_member() {
6445        // A DID that already holds beta access must get `already_seated` with NO
6446        // code/url — the bot posts nothing. This is the server-side backstop that
6447        // survives a bot-host state loss (it would otherwise re-mint + re-post).
6448        let state = bot_state("bot-secret-abcdef").await;
6449        store::grant_access(&state.db, "did:plc:member", None, "admin", None)
6450            .await
6451            .unwrap();
6452        let app = router(state.clone());
6453        let (status, json) = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:member").await;
6454        assert_eq!(status, StatusCode::OK);
6455        assert_eq!(json["status"], "already_seated");
6456        assert_eq!(json["code"], "");
6457        assert_eq!(json["url"], "");
6458        // No new invite code was minted for the seated DID.
6459        assert!(store::find_active_code_for_did(&state.db, "did:plc:member")
6460            .await
6461            .unwrap()
6462            .is_none());
6463    }
6464
6465    #[tokio::test]
6466    async fn bot_claims_is_idempotent_per_did_returns_same_code() {
6467        // Two mint requests for the SAME follower DID must return the SAME code
6468        // (the app is authoritative), never a second one — so a bot-host state loss
6469        // re-requesting cannot double-mint or double-post.
6470        let state = bot_state("bot-secret-abcdef").await;
6471        let app = router(state.clone());
6472
6473        let (s1, j1) = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:follower1").await;
6474        assert_eq!(s1, StatusCode::OK);
6475        assert_eq!(j1["status"], "minted");
6476        let code1 = j1["code"].as_str().unwrap().to_string();
6477
6478        let (s2, j2) = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:follower1").await;
6479        assert_eq!(s2, StatusCode::OK);
6480        assert_eq!(j2["status"], "existing");
6481        assert_eq!(j2["code"].as_str().unwrap(), code1, "same code returned");
6482        assert_eq!(j2["url"], j1["url"], "same url returned");
6483
6484        // Exactly ONE active code exists for that DID.
6485        assert_eq!(store::count_active_codes(&state.db).await.unwrap(), 1);
6486    }
6487
6488    #[tokio::test]
6489    async fn bot_claims_records_intended_did_at_mint() {
6490        // A fresh mint records the follower DID so the lookup finds it.
6491        let state = bot_state("bot-secret-abcdef").await;
6492        let app = router(state.clone());
6493        let (status, json) =
6494            post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:follower2").await;
6495        assert_eq!(status, StatusCode::OK);
6496        let code = json["code"].as_str().unwrap();
6497        assert_eq!(
6498            store::find_active_code_for_did(&state.db, "did:plc:follower2")
6499                .await
6500                .unwrap()
6501                .as_deref(),
6502            Some(code)
6503        );
6504    }
6505
6506    #[tokio::test]
6507    async fn bot_claims_concurrent_same_did_never_double_mints() {
6508        // S4: two concurrent /bot/claims for ONE follower DID must not both mint an
6509        // active code. The dedupe check (3b) and the mint are separate statements,
6510        // so a race can slip both past 3b's `Ok(None)`; the partial unique index
6511        // then makes the loser's INSERT conflict, and the handler recovers by
6512        // returning the winner's code (status `existing`) rather than 500-ing.
6513        // Result: exactly ONE active code, and BOTH callers get a usable code.
6514        let state = bot_state("bot-secret-abcdef").await;
6515        let app = router(state.clone());
6516
6517        let a = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:racer");
6518        let b = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:racer");
6519        let ((sa, ja), (sb, jb)) = tokio::join!(a, b);
6520
6521        assert_eq!(sa, StatusCode::OK, "first response: {ja:?}");
6522        assert_eq!(sb, StatusCode::OK, "second response: {jb:?}");
6523
6524        // Exactly one active code for the DID — the whole point of the fix.
6525        assert_eq!(
6526            store::count_active_codes(&state.db).await.unwrap(),
6527            1,
6528            "concurrent mints must not create two active codes"
6529        );
6530
6531        // Both callers received the SAME (single) code, and neither got a 500.
6532        let ca = ja["code"].as_str().unwrap_or("");
6533        let cb = jb["code"].as_str().unwrap_or("");
6534        assert!(!ca.is_empty() && !cb.is_empty(), "both must return a code");
6535        assert_eq!(ca, cb, "both callers must get the one minted code");
6536        // One is `minted` (the winner), the other `minted` or `existing` depending
6537        // on interleaving — but never an error status.
6538        for st in [&ja["status"], &jb["status"]] {
6539            let s = st.as_str().unwrap_or("");
6540            assert!(s == "minted" || s == "existing", "unexpected status {s:?}");
6541        }
6542    }
6543
6544    #[tokio::test]
6545    async fn bot_claims_rejects_malformed_json_body() {
6546        let state = bot_state("bot-secret-abcdef").await;
6547        let app = router(state);
6548        let resp = app
6549            .oneshot(
6550                Request::builder()
6551                    .method("POST")
6552                    .uri("/bot/claims")
6553                    .header("x-bot-secret", "bot-secret-abcdef")
6554                    .header("content-type", "application/json")
6555                    .body(Body::from("{not json"))
6556                    .unwrap(),
6557            )
6558            .await
6559            .unwrap();
6560        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
6561    }
6562
6563    #[tokio::test]
6564    async fn favicon_ico_served_at_root() {
6565        // Browsers request bare /favicon.ico regardless of the <link rel="icon">
6566        // tags in <head>; the root route must serve the icon, not 404.
6567        let state = test_state(&[]).await;
6568        let app = router(state);
6569        let resp = app
6570            .oneshot(
6571                Request::builder()
6572                    .uri("/favicon.ico")
6573                    .body(Body::empty())
6574                    .unwrap(),
6575            )
6576            .await
6577            .unwrap();
6578        assert_eq!(resp.status(), StatusCode::OK);
6579        let ct = resp
6580            .headers()
6581            .get(header::CONTENT_TYPE)
6582            .unwrap()
6583            .to_str()
6584            .unwrap();
6585        assert!(
6586            ct.contains("icon") || ct.starts_with("image/"),
6587            "content-type = {ct}"
6588        );
6589    }
6590
6591    #[tokio::test]
6592    async fn login_without_invite_redirects_to_beta_redeem() {
6593        // No allow-list seed, no invite cookie: starting OAuth must be refused.
6594        let state = test_state(&[]).await;
6595        let app = router(state);
6596        let resp = app
6597            .oneshot(
6598                Request::builder()
6599                    .method("POST")
6600                    .uri("/login")
6601                    .header("content-type", "application/x-www-form-urlencoded")
6602                    .body(Body::from("handle=alice.bsky.social"))
6603                    .unwrap(),
6604            )
6605            .await
6606            .unwrap();
6607        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
6608        assert_eq!(
6609            resp.headers().get(header::LOCATION).unwrap(),
6610            "/beta/redeem"
6611        );
6612    }
6613
6614    #[tokio::test]
6615    async fn login_with_valid_invite_cookie_starts_oauth() {
6616        let state = test_state(&[]).await;
6617        let cookie = sign_invite("FEATHER-ABCDWXYZ", &state.config.cookie_secret);
6618        let cookie = cookie.split(';').next().unwrap().to_string();
6619        let app = router(state);
6620        let resp = app
6621            .oneshot(
6622                Request::builder()
6623                    .method("POST")
6624                    .uri("/login")
6625                    .header("content-type", "application/x-www-form-urlencoded")
6626                    .header(header::COOKIE, cookie)
6627                    .body(Body::from("handle=alice.bsky.social"))
6628                    .unwrap(),
6629            )
6630            .await
6631            .unwrap();
6632        // Redirects into the sidecar login (not to /beta/redeem).
6633        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
6634        let loc = resp
6635            .headers()
6636            .get(header::LOCATION)
6637            .unwrap()
6638            .to_str()
6639            .unwrap();
6640        assert!(loc.contains("/login"), "loc = {loc}");
6641        assert_ne!(loc, "/beta/redeem");
6642    }
6643
6644    /// A resolver that always fails — proves the fast paths short-circuit BEFORE
6645    /// any network resolution and that a resolution failure fails closed.
6646    async fn resolver_never(_handle: String) -> Option<String> {
6647        None
6648    }
6649
6650    /// A resolver that maps every handle to `did`.
6651    fn resolver_to(did: &'static str) -> impl FnOnce(String) -> std::future::Ready<Option<String>> {
6652        move |_handle| std::future::ready(Some(did.to_string()))
6653    }
6654
6655    /// Path 3: a cookie-less visitor whose submitted handle resolves to a DID
6656    /// that already holds a seat (the seeded-admin first-login case) passes the
6657    /// gate — no session cookie, no invite code.
6658    #[tokio::test]
6659    async fn may_start_oauth_honors_seat_via_resolved_handle() {
6660        // The seeded admin holds a seat (via ALLOWED_DIDS → ensure_seed) but has
6661        // no cookie on a fresh deploy.
6662        let state = test_state(&["did:plc:admin"]).await;
6663        let headers = HeaderMap::new();
6664        assert!(
6665            may_start_oauth_with(
6666                &state,
6667                &headers,
6668                "admin.example",
6669                resolver_to("did:plc:admin")
6670            )
6671            .await,
6672            "a handle resolving to a seated DID must pass the gate"
6673        );
6674    }
6675
6676    /// Path 3, negative: a handle that resolves to a DID with NO seat is bounced
6677    /// — the anti-abuse intent is preserved (resolution succeeds, seat check
6678    /// fails).
6679    #[tokio::test]
6680    async fn may_start_oauth_bounces_non_member_handle() {
6681        let state = test_state(&["did:plc:admin"]).await;
6682        let headers = HeaderMap::new();
6683        assert!(
6684            !may_start_oauth_with(
6685                &state,
6686                &headers,
6687                "rando.example",
6688                resolver_to("did:plc:rando")
6689            )
6690            .await,
6691            "a resolved DID with no seat must be bounced"
6692        );
6693    }
6694
6695    /// Fail-closed: an unresolvable/malformed handle (resolver returns `None`)
6696    /// bounces gracefully — no panic, no handshake.
6697    #[tokio::test]
6698    async fn may_start_oauth_fails_closed_on_unresolvable_handle() {
6699        let state = test_state(&["did:plc:admin"]).await;
6700        let headers = HeaderMap::new();
6701        assert!(
6702            !may_start_oauth_with(&state, &headers, "not a handle", resolver_never).await,
6703            "an unresolvable handle must fail closed"
6704        );
6705    }
6706
6707    /// The session-cookie fast path admits a seated member WITHOUT calling the
6708    /// resolver (proven by injecting `resolver_never`, which would otherwise
6709    /// bounce).
6710    #[tokio::test]
6711    async fn may_start_oauth_session_cookie_shortcircuits_resolution() {
6712        let state = test_state(&[]).await;
6713        let did = "did:plc:member";
6714        store::grant_access(&state.db, did, Some("member.example"), "test", None)
6715            .await
6716            .unwrap();
6717        let cookie = session_cookie(&state, did, Some("member.example"));
6718        let mut headers = HeaderMap::new();
6719        headers.insert(header::COOKIE, cookie.parse().unwrap());
6720        assert!(
6721            may_start_oauth_with(&state, &headers, "member.example", resolver_never).await,
6722            "a seated session cookie must pass without resolution"
6723        );
6724    }
6725
6726    /// The invite-cookie fast path admits WITHOUT calling the resolver.
6727    #[tokio::test]
6728    async fn may_start_oauth_invite_cookie_shortcircuits_resolution() {
6729        let state = test_state(&[]).await;
6730        let cookie = sign_invite("FEATHER-ABCDWXYZ", &state.config.cookie_secret);
6731        let cookie = cookie.split(';').next().unwrap().to_string();
6732        let mut headers = HeaderMap::new();
6733        headers.insert(header::COOKIE, cookie.parse().unwrap());
6734        assert!(
6735            may_start_oauth_with(&state, &headers, "someone.example", resolver_never).await,
6736            "a valid invite cookie must pass without resolution"
6737        );
6738    }
6739
6740    #[tokio::test]
6741    async fn admin_mint_requires_admin_seed_did() {
6742        let state = test_state(&["did:plc:admin"]).await;
6743        // A non-admin (but beta'd) session is forbidden.
6744        store::grant_access(&state.db, "did:plc:rando", None, "test", None)
6745            .await
6746            .unwrap();
6747        let rando_cookie = session_cookie(&state, "did:plc:rando", None);
6748        // An admin session is allowed.
6749        let admin_cookie = session_cookie(&state, "did:plc:admin", None);
6750        let app = router(state);
6751
6752        let forbidden = app
6753            .clone()
6754            .oneshot(
6755                Request::builder()
6756                    .method("POST")
6757                    .uri("/admin/invites?n=2")
6758                    .header(header::COOKIE, rando_cookie)
6759                    .body(Body::empty())
6760                    .unwrap(),
6761            )
6762            .await
6763            .unwrap();
6764        assert_eq!(forbidden.status(), StatusCode::FORBIDDEN);
6765
6766        let ok = app
6767            .oneshot(
6768                Request::builder()
6769                    .method("POST")
6770                    .uri("/admin/invites?n=2")
6771                    .header(header::COOKIE, admin_cookie)
6772                    .body(Body::empty())
6773                    .unwrap(),
6774            )
6775            .await
6776            .unwrap();
6777        assert_eq!(ok.status(), StatusCode::OK);
6778        let bytes = axum::body::to_bytes(ok.into_body(), 64 * 1024)
6779            .await
6780            .unwrap();
6781        let body = String::from_utf8(bytes.to_vec()).unwrap();
6782        let minted: Vec<&str> = body.lines().filter(|l| !l.is_empty()).collect();
6783        assert_eq!(minted.len(), 2);
6784        assert!(minted.iter().all(|c| c.starts_with("FEATHER-")));
6785    }
6786
6787    #[tokio::test]
6788    async fn admin_mint_unauthenticated_is_401() {
6789        let state = test_state(&["did:plc:admin"]).await;
6790        let app = router(state);
6791        let resp = app
6792            .oneshot(
6793                Request::builder()
6794                    .method("POST")
6795                    .uri("/admin/invites")
6796                    .body(Body::empty())
6797                    .unwrap(),
6798            )
6799            .await
6800            .unwrap();
6801        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
6802    }
6803
6804    /// A state whose `/about` renders the adoption line, seeded with one
6805    /// observation.
6806    async fn adoption_state(repos: i64, truncated: bool) -> AppState {
6807        let db = store::init_url("sqlite::memory:").await.unwrap();
6808        store::record_network_stat(
6809            &db,
6810            &store::NetworkStat {
6811                key: store::ADOPTION_STAT_KEY.to_string(),
6812                source: "https://relay1.us-west.bsky.network".to_string(),
6813                value: repos,
6814                truncated,
6815                observed_at: "2026-08-13T04:05:06Z".to_string(),
6816            },
6817        )
6818        .await
6819        .unwrap();
6820        let config = Config {
6821            cookie_secret: "test-cookie-secret-000".to_string(),
6822            show_adoption: true,
6823            ..Config::default()
6824        };
6825        AppState::new(config, db).unwrap()
6826    }
6827
6828    async fn about_body(state: AppState) -> String {
6829        let resp = router(state)
6830            .oneshot(
6831                Request::builder()
6832                    .uri("/about")
6833                    .body(Body::empty())
6834                    .unwrap(),
6835            )
6836            .await
6837            .unwrap();
6838        assert_eq!(resp.status(), StatusCode::OK);
6839        let bytes = axum::body::to_bytes(resp.into_body(), 256 * 1024)
6840            .await
6841            .unwrap();
6842        String::from_utf8(bytes.to_vec()).unwrap()
6843    }
6844
6845    /// Default config ⇒ the flag is off ⇒ no line, and no query is issued.
6846    #[tokio::test]
6847    async fn about_omits_adoption_line_by_default() {
6848        let state = test_state(&[]).await;
6849        assert!(!state.config.show_adoption);
6850        let body = about_body(state).await;
6851        assert!(
6852            !body.contains("atproto network"),
6853            "the adoption line must not render by default"
6854        );
6855    }
6856
6857    #[tokio::test]
6858    async fn about_renders_adoption_line_when_enabled() {
6859        // **A distinctive count, and asserted IN ITS SENTENCE.**
6860        //
6861        // This used to seed 4 and assert `body.contains("4")`, which the
6862        // colophon's `width="44"` satisfies whatever the count is — so
6863        // hardcoding the rendered number passed. Both halves are needed: a
6864        // digit that does not occur incidentally, and the assertion tied to the
6865        // phrase it belongs to.
6866        let body = about_body(adoption_state(7_318, false).await).await;
6867        // The count and its phrase are on separate template lines, so compare
6868        // against a whitespace-collapsed copy rather than the raw HTML.
6869        let flat = body.split_whitespace().collect::<Vec<_>>().join(" ");
6870        assert!(
6871            flat.contains("7318 accounts on the atproto network hold"),
6872            "the count did not render in its own sentence: {flat}",
6873        );
6874        assert!(
6875            body.contains("accounts on the atproto network hold"),
6876            "{body}"
6877        );
6878        assert!(
6879            body.contains("2026-08-13"),
6880            "the observation date must render"
6881        );
6882        assert!(
6883            body.contains("lower bound"),
6884            "the non-archival caveat must ride along with the number"
6885        );
6886        assert!(
6887            !body.contains("At least"),
6888            "an untruncated count is exact-ish"
6889        );
6890    }
6891
6892    /// A count of one must read as "1 account … holds", not "1 accounts … hold".
6893    #[tokio::test]
6894    async fn about_adoption_line_is_singular_at_one() {
6895        let body = about_body(adoption_state(1, false).await).await;
6896        assert!(
6897            body.contains("account on the atproto network holds"),
6898            "{body}"
6899        );
6900    }
6901
6902    /// A truncated observation is a floor, and must say so.
6903    #[tokio::test]
6904    async fn about_adoption_line_says_at_least_when_truncated() {
6905        let body = about_body(adoption_state(25_000, true).await).await;
6906        assert!(body.contains("At least"), "{body}");
6907    }
6908
6909    /// Flag on but no observation (or a zero) ⇒ 200, no line, no error.
6910    #[tokio::test]
6911    async fn about_omits_line_when_enabled_with_no_observation() {
6912        let db = store::init_url("sqlite::memory:").await.unwrap();
6913        let config = Config {
6914            cookie_secret: "test-cookie-secret-000".to_string(),
6915            show_adoption: true,
6916            ..Config::default()
6917        };
6918        let body = about_body(AppState::new(config, db).unwrap()).await;
6919        assert!(!body.contains("atproto network"));
6920    }
6921
6922    #[tokio::test]
6923    async fn cache_control_public_on_about_no_store_on_authed() {
6924        let state = test_state(&["did:plc:admin"]).await;
6925        let admin_cookie = session_cookie(&state, "did:plc:admin", None);
6926        let app = router(state);
6927
6928        // /about → public, cacheable.
6929        let about = app
6930            .clone()
6931            .oneshot(
6932                Request::builder()
6933                    .uri("/about")
6934                    .body(Body::empty())
6935                    .unwrap(),
6936            )
6937            .await
6938            .unwrap();
6939        assert_eq!(
6940            about.headers().get(header::CACHE_CONTROL).unwrap(),
6941            "public, max-age=300"
6942        );
6943        // The security headers are still intact.
6944        // The VALUE, spelled out here rather than compared to the constant —
6945        // `== CONTENT_SECURITY_POLICY` passes with the constant gutted. This
6946        // used to assert only that the header existed, which a policy of
6947        // `default-src *` satisfies.
6948        assert_eq!(
6949            about.headers()["content-security-policy"],
6950            EXPECTED_CSP,
6951            "the CSP is not the policy the router promises"
6952        );
6953        assert_eq!(about.headers().get("x-frame-options").unwrap(), "DENY");
6954
6955        // /privacy and /terms are static public pages → public, cacheable.
6956        for path in ["/privacy", "/terms"] {
6957            let resp = app
6958                .clone()
6959                .oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
6960                .await
6961                .unwrap();
6962            assert_eq!(resp.status(), StatusCode::OK);
6963            assert_eq!(
6964                resp.headers().get(header::CACHE_CONTROL).unwrap(),
6965                "public, max-age=300",
6966                "{path} should be publicly cacheable"
6967            );
6968            // Security headers apply to these pages too.
6969            assert_eq!(resp.headers()["content-security-policy"], EXPECTED_CSP);
6970            assert_eq!(resp.headers().get("x-frame-options").unwrap(), "DENY");
6971        }
6972
6973        // The bare /login landing → public, cacheable.
6974        let login = app
6975            .clone()
6976            .oneshot(
6977                Request::builder()
6978                    .uri("/login")
6979                    .body(Body::empty())
6980                    .unwrap(),
6981            )
6982            .await
6983            .unwrap();
6984        assert_eq!(
6985            login.headers().get(header::CACHE_CONTROL).unwrap(),
6986            "public, max-age=300"
6987        );
6988
6989        // An authenticated page → no-store.
6990        let home = app
6991            .oneshot(
6992                Request::builder()
6993                    .uri("/")
6994                    .header(header::COOKIE, admin_cookie)
6995                    .body(Body::empty())
6996                    .unwrap(),
6997            )
6998            .await
6999            .unwrap();
7000        assert_eq!(
7001            home.headers().get(header::CACHE_CONTROL).unwrap(),
7002            "no-store"
7003        );
7004    }
7005
7006    #[tokio::test]
7007    async fn beta_redeem_page_renders() {
7008        let state = test_state(&[]).await;
7009        let app = router(state);
7010        let resp = app
7011            .oneshot(
7012                Request::builder()
7013                    .uri("/beta/redeem")
7014                    .body(Body::empty())
7015                    .unwrap(),
7016            )
7017            .await
7018            .unwrap();
7019        assert_eq!(resp.status(), StatusCode::OK);
7020        let bytes = axum::body::to_bytes(resp.into_body(), 256 * 1024)
7021            .await
7022            .unwrap();
7023        let html = String::from_utf8(bytes.to_vec()).unwrap();
7024        assert!(html.contains("Invite code"));
7025        assert!(html.contains("/beta/redeem"));
7026    }
7027
7028    #[tokio::test]
7029    async fn rate_limit_returns_429_after_burst() {
7030        // Configure a trusted proxy header so the limiter keys on the forwarded
7031        // IP (the oneshot harness sets no ConnectInfo socket peer).
7032        let db = store::init_url("sqlite::memory:").await.unwrap();
7033        store::ensure_seed(&db, &[]).await.unwrap();
7034        let config = Config {
7035            cookie_secret: "test-cookie-secret-000".to_string(),
7036            beta_cap: 3,
7037            trusted_ip_header: Some("cf-connecting-ip".to_string()),
7038            ..Config::default()
7039        };
7040        let state = AppState::new(config, db).unwrap();
7041        let app = router(state);
7042        // Hammer POST /beta/redeem past the burst from a single (trusted) IP. The
7043        // handler itself returns 200 (re-render) on a bad code; the limiter is
7044        // what eventually yields 429.
7045        let mut saw_429 = false;
7046        for _ in 0..(RATE_BURST as usize + 5) {
7047            let resp = app
7048                .clone()
7049                .oneshot(
7050                    Request::builder()
7051                        .method("POST")
7052                        .uri("/beta/redeem")
7053                        .header("content-type", "application/x-www-form-urlencoded")
7054                        .header("cf-connecting-ip", "203.0.113.200")
7055                        .body(Body::from("code=FEATHER-NOPENOPE"))
7056                        .unwrap(),
7057                )
7058                .await
7059                .unwrap();
7060            if resp.status() == StatusCode::TOO_MANY_REQUESTS {
7061                saw_429 = true;
7062                break;
7063            }
7064        }
7065        assert!(saw_429, "expected a 429 after exhausting the burst");
7066    }
7067
7068    /// **A forged `X-Forwarded-For` does not key the limiter** — the property
7069    /// the middleware's comment cites this test as proof of.
7070    ///
7071    /// The previous version rotated the forged header and asserted that no
7072    /// request was EVER 429'd. Rotating addresses can never exhaust a per-IP
7073    /// burst, so that assertion held whether the header was trusted or
7074    /// ignored — it passed in the vulnerable configuration too. And with no
7075    /// socket peer the limiter fails open, so nothing could have been keyed on
7076    /// anything. Now one real peer sends `RATE_BURST + 5` requests, each with
7077    /// a DIFFERENT forged header, and the last must be 429: they all landed in
7078    /// the peer's bucket. A limiter keying on the header mints a fresh bucket
7079    /// per request and never trips — which is exactly what the mutation does.
7080    #[tokio::test]
7081    async fn a_forged_forwarded_for_header_does_not_key_the_limiter() {
7082        let state = test_state(&[]).await;
7083        assert!(
7084            state.config.trusted_ip_header.is_none(),
7085            "no proxy header is trusted here"
7086        );
7087        let app = router(state);
7088        let peer = std::net::SocketAddr::from(([203, 0, 113, 7], 40000));
7089        let mut saw_429 = false;
7090        for i in 0..(RATE_BURST as usize + 5) {
7091            let forged = format!("10.9.8.{}", i % 250);
7092            let resp = app
7093                .clone()
7094                .oneshot(
7095                    Request::builder()
7096                        .method("POST")
7097                        .uri("/beta/redeem")
7098                        .header("content-type", "application/x-www-form-urlencoded")
7099                        .header("x-forwarded-for", forged)
7100                        .extension(axum::extract::ConnectInfo(peer))
7101                        .body(Body::from("code=FEATHER-NOPENOPE"))
7102                        .unwrap(),
7103                )
7104                .await
7105                .unwrap();
7106            if resp.status() == StatusCode::TOO_MANY_REQUESTS {
7107                saw_429 = true;
7108                break;
7109            }
7110        }
7111        assert!(
7112            saw_429,
7113            "rotating a forged X-Forwarded-For minted fresh buckets: the limiter is keyed on an attacker-chosen header"
7114        );
7115    }
7116
7117    // -- OPML import body cap (DefaultBodyLimit → 413) -------------------------
7118
7119    /// **A private feed is refused BEFORE it is fetched.** The add path's
7120    /// privacy gate had no test at all — `private_feeds_are_classified_private_
7121    /// across_providers` says "the add + OPML paths both gate on this
7122    /// classifier" and nothing checked either. The gate exists so a
7123    /// token-bearing URL never reaches the network; the assertion that
7124    /// matters is the server's hit count: zero.
7125    #[tokio::test]
7126    async fn subscribing_to_a_private_feed_never_reaches_the_network() {
7127        let did = "did:plc:privateadder";
7128        let state = test_state_with_caps(did, 0, 0).await;
7129        let (base, hits) = crate::net::tests::serve_body_counted(b"<rss/>".to_vec()).await;
7130        let port: u16 = base
7131            .trim_end_matches('/')
7132            .rsplit(':')
7133            .next()
7134            .unwrap()
7135            .parse()
7136            .unwrap();
7137        crate::net::test_host_override(
7138            "private-add.test",
7139            std::net::SocketAddr::from(([127, 0, 0, 1], port)),
7140        );
7141        let cookie = session_cookie(&state, did, None);
7142        let resp = router(state.clone())
7143            .oneshot(
7144                Request::builder()
7145                    .method("POST")
7146                    .uri("/subscriptions")
7147                    .header(header::COOKIE, cookie)
7148                    .header("content-type", "application/x-www-form-urlencoded")
7149                    .body(Body::from(format!(
7150                        "url=http%3A%2F%2Fprivate-add.test%3A{port}%2Ffeed%2Fprivate%2Fdeadbeefcafe1234"
7151                    )))
7152                    .unwrap(),
7153            )
7154            .await
7155            .unwrap();
7156        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
7157        let loc = resp
7158            .headers()
7159            .get(header::LOCATION)
7160            .unwrap()
7161            .to_str()
7162            .unwrap();
7163        assert!(loc.contains("Private"), "not refused as private: {loc}");
7164        assert_eq!(
7165            hits.load(std::sync::atomic::Ordering::SeqCst),
7166            0,
7167            "the private feed was FETCHED before being refused"
7168        );
7169        assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
7170    }
7171
7172    /// **OPML import skips a private feed without storing or publishing it.**
7173    /// The import path does not fetch, so "never fetched" is not the signal
7174    /// here; "never stored, never written to the PDS" is. The batch write's
7175    /// bytes are captured and must not carry the URL.
7176    #[tokio::test]
7177    async fn opml_import_skips_a_private_feed_without_storing_or_publishing_it() {
7178        let did = "did:plc:renamer4";
7179        let (sidecar, bodies) = spawn_logging_sidecar().await;
7180        let state = test_state_with_sidecar(&[did], &sidecar).await;
7181        let tokened = "https://www.patreon.com/rss/author?auth=Zm9vYmFyc2VjcmV0dG9rZW4";
7182        let opml = format!(
7183            "<?xml version=\"1.0\"?>\n<opml version=\"2.0\"><head><title>t</title></head><body>\n\
7184             <outline type=\"rss\" text=\"Public\" xmlUrl=\"https://public.example/feed.xml\"/>\n\
7185             <outline type=\"rss\" text=\"Paid\" xmlUrl=\"{tokened}\"/>\n\
7186             </body></opml>"
7187        );
7188        let (ct, body) = opml_multipart(opml.as_bytes());
7189        let cookie = session_cookie(&state, did, None);
7190        let resp = router(state.clone())
7191            .oneshot(
7192                Request::builder()
7193                    .method("POST")
7194                    .uri("/opml")
7195                    .header(header::COOKIE, cookie)
7196                    .header("content-type", ct)
7197                    .body(Body::from(body))
7198                    .unwrap(),
7199            )
7200            .await
7201            .unwrap();
7202        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
7203        let loc = resp
7204            .headers()
7205            .get(header::LOCATION)
7206            .unwrap()
7207            .to_str()
7208            .unwrap();
7209        assert!(
7210            loc.contains("skipped%20as%20private"),
7211            "not reported as skipped: {loc}"
7212        );
7213        assert!(store::get_feed_by_url(&state.db, tokened)
7214            .await
7215            .unwrap()
7216            .is_none());
7217        let sent = bodies.lock().unwrap().join("\n");
7218        assert!(
7219            sent.contains("public.example"),
7220            "the public feed was not written: {sent}"
7221        );
7222        assert!(
7223            !sent.contains("Zm9vYmFyc2VjcmV0dG9rZW4"),
7224            "the secret was PUBLISHED to the PDS: {sent}"
7225        );
7226    }
7227
7228    /// **`GET /login?handle=` is gated like `POST /login`.** Only the POST was
7229    /// tested; the GET form starts the same handshake and had no test, so
7230    /// deleting its gate left the suite green.
7231    #[tokio::test]
7232    async fn get_login_without_a_seat_is_refused() {
7233        let state = test_state(&[]).await;
7234        let resp = router(state)
7235            .oneshot(
7236                Request::builder()
7237                    .method("GET")
7238                    .uri("/login?handle=alice.bsky.social")
7239                    .body(Body::empty())
7240                    .unwrap(),
7241            )
7242            .await
7243            .unwrap();
7244        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
7245        assert_eq!(
7246            resp.headers().get(header::LOCATION).unwrap(),
7247            "/beta/redeem"
7248        );
7249    }
7250
7251    /// A sidecar fake that answers every request `ok` and records the PATH of
7252    /// each in arrival order, plus every body — for asserting what was sent,
7253    /// and in what order.
7254    async fn spawn_logging_sidecar() -> (String, std::sync::Arc<std::sync::Mutex<Vec<String>>>) {
7255        use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
7256        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7257        let addr = listener.local_addr().unwrap();
7258        let log = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
7259        let sink = log.clone();
7260        tokio::spawn(async move {
7261            loop {
7262                let Ok((mut sock, _)) = listener.accept().await else {
7263                    break;
7264                };
7265                let mut raw: Vec<u8> = Vec::new();
7266                let mut chunk = [0u8; 4096];
7267                let text = loop {
7268                    let Ok(n) = sock.read(&mut chunk).await else {
7269                        break String::new();
7270                    };
7271                    if n == 0 {
7272                        break String::from_utf8_lossy(&raw).to_string();
7273                    }
7274                    raw.extend_from_slice(&chunk[..n]);
7275                    let Some(split) = raw.windows(4).position(|w| w == b"\r\n\r\n") else {
7276                        continue;
7277                    };
7278                    let (head, body) = raw.split_at(split + 4);
7279                    let want = String::from_utf8_lossy(head).lines().find_map(|l| {
7280                        let (k, v) = l.split_once(':')?;
7281                        k.eq_ignore_ascii_case("content-length")
7282                            .then(|| v.trim().parse::<usize>().ok())?
7283                    });
7284                    if want.is_none_or(|w| body.len() >= w) {
7285                        break String::from_utf8_lossy(&raw).to_string();
7286                    }
7287                };
7288                let path = text
7289                    .lines()
7290                    .next()
7291                    .and_then(|l| l.split_whitespace().nth(1))
7292                    .unwrap_or("")
7293                    .to_string();
7294                let body_text = text
7295                    .split_once("\r\n\r\n")
7296                    .map(|(_, b)| b)
7297                    .unwrap_or("")
7298                    .to_string();
7299                sink.lock().unwrap().push(format!("{path} {body_text}"));
7300                let body = serde_json::json!({ "ok": true, "did": "did:plc:x", "revoked": true, "hadSession": true, "data": {"uri": "at://did:plc:x/c/r", "cid": "bafy"} }).to_string();
7301                let resp = format!(
7302                    "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
7303                    body.len(),
7304                    body
7305                );
7306                let _ = sock.write_all(resp.as_bytes()).await;
7307                let _ = sock.flush().await;
7308            }
7309        });
7310        (format!("http://{addr}"), log)
7311    }
7312
7313    /// **Sign-out flushes dirty read-state BEFORE it revokes — through the
7314    /// route.** The previous version of this test called
7315    /// `flush_before_revoke` and `revoke_everywhere` itself and asserted one
7316    /// flush attempt; its doc claimed deleting the call from the handler
7317    /// "drops that to zero", which was false — the handler was never run.
7318    /// Deleting the call left the suite green: #117 regressing in full, with
7319    /// the test named after it still passing. Now `POST /logout` is driven and
7320    /// the sidecar's log must show a repo write BEFORE the revoke.
7321    #[tokio::test]
7322    async fn signing_out_flushes_before_it_revokes_through_the_route() {
7323        let did = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
7324        let (sidecar, log) = spawn_logging_sidecar().await;
7325        let state = test_state_with_sidecar(&[did], &sidecar).await;
7326        crate::store::upsert_cursor(
7327            &state.db,
7328            &crate::store::ReadCursor {
7329                did: did.to_string(),
7330                feed_url: "https://example.com/feed.xml".into(),
7331                read_through: None,
7332                read_ids: "[\"1\"]".into(),
7333                unread_ids: "[]".into(),
7334                dirty: true,
7335                pds_created: false,
7336                updated_at: "2026-09-13T21:22:40Z".into(),
7337            },
7338        )
7339        .await
7340        .unwrap();
7341        let cookie = session_cookie(&state, did, None);
7342        let resp = router(state.clone())
7343            .oneshot(
7344                Request::builder()
7345                    .method("POST")
7346                    .uri("/logout")
7347                    .header(header::COOKIE, cookie)
7348                    .body(Body::empty())
7349                    .unwrap(),
7350            )
7351            .await
7352            .unwrap();
7353        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
7354
7355        let entries = log.lock().unwrap().clone();
7356        let flush = entries
7357            .iter()
7358            .position(|e| e.starts_with("/internal/repo "));
7359        let revoke = entries
7360            .iter()
7361            .position(|e| e.starts_with("/internal/revoke "));
7362        assert!(revoke.is_some(), "sign-out did not revoke: {entries:?}");
7363        assert!(
7364            flush.is_some(),
7365            "sign-out did not attempt a flush before revoking: {entries:?}"
7366        );
7367        assert!(
7368            flush < revoke,
7369            "the flush arrived AFTER the revoke — no session left to send it with: {entries:?}"
7370        );
7371    }
7372
7373    /// The policy, as a literal: the backstop the router calls "neutralises any
7374    /// XSS that slips past sanitization". `script-src 'self'` and no
7375    /// `'unsafe-inline'` on it are the two clauses that make it one.
7376    const EXPECTED_CSP: &str = "default-src 'self'; \
7377     script-src 'self'; \
7378     style-src 'self' 'unsafe-inline'; \
7379     img-src 'self' https: data:; \
7380     font-src 'self'; \
7381     connect-src 'self'; \
7382     form-action 'self'; \
7383     base-uri 'self'; \
7384     frame-ancestors 'none'; \
7385     object-src 'none'";
7386
7387    /// Build a `multipart/form-data` body carrying a single `file` field whose
7388    /// contents are `payload`, returning `(content_type, body_bytes)`.
7389    fn opml_multipart(payload: &[u8]) -> (String, Vec<u8>) {
7390        let boundary = "----featherreadertestboundary";
7391        let mut body = Vec::new();
7392        body.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
7393        body.extend_from_slice(
7394            b"Content-Disposition: form-data; name=\"file\"; filename=\"feeds.opml\"\r\n",
7395        );
7396        body.extend_from_slice(b"Content-Type: text/x-opml\r\n\r\n");
7397        body.extend_from_slice(payload);
7398        body.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes());
7399        (format!("multipart/form-data; boundary={boundary}"), body)
7400    }
7401
7402    #[tokio::test]
7403    async fn opml_import_oversize_upload_returns_413() {
7404        let state = test_state(&["did:plc:admin"]).await;
7405        let cookie = session_cookie(&state, "did:plc:admin", None);
7406        let app = router(state);
7407
7408        // A payload comfortably above the route cap.
7409        let payload = vec![b'a'; OPML_BODY_LIMIT + 1024];
7410        let (content_type, body) = opml_multipart(&payload);
7411
7412        let resp = app
7413            .oneshot(
7414                Request::builder()
7415                    .method("POST")
7416                    .uri("/opml")
7417                    .header("content-type", content_type)
7418                    .header(header::COOKIE, cookie)
7419                    .body(Body::from(body))
7420                    .unwrap(),
7421            )
7422            .await
7423            .unwrap();
7424        assert_eq!(
7425            resp.status(),
7426            StatusCode::PAYLOAD_TOO_LARGE,
7427            "an over-cap OPML upload must be rejected with 413, not collapsed to 500"
7428        );
7429    }
7430
7431    /// **The route's own cap is what refuses this, not the framework's.**
7432    ///
7433    /// `OPML_BODY_LIMIT` used to equal axum's `DefaultBodyLimit` (2 MiB), so
7434    /// the route's layer was a no-op — deleting it left every test green, and
7435    /// `opml_import_oversize_upload_returns_413` was really testing axum. The
7436    /// limit is 1 MiB now, strictly tighter, and this uploads a payload that
7437    /// sits BETWEEN the two: over ours, under the framework's. Only the
7438    /// route's layer can refuse it — remove the layer and this payload is
7439    /// accepted, which is also what demonstrates the framework's default is
7440    /// the larger of the two.
7441    #[tokio::test]
7442    async fn opml_import_over_the_route_cap_is_refused_below_the_framework_default() {
7443        let state = test_state(&["did:plc:admin"]).await;
7444        let cookie = session_cookie(&state, "did:plc:admin", None);
7445        let app = router(state);
7446
7447        // Between the two ceilings: the framework would accept this.
7448        let payload = vec![b'a'; (OPML_BODY_LIMIT + AXUM_DEFAULT_BODY_LIMIT) / 2];
7449        let (content_type, body) = opml_multipart(&payload);
7450
7451        let resp = app
7452            .oneshot(
7453                Request::builder()
7454                    .method("POST")
7455                    .uri("/opml")
7456                    .header("content-type", content_type)
7457                    .header(header::COOKIE, cookie)
7458                    .body(Body::from(body))
7459                    .unwrap(),
7460            )
7461            .await
7462            .unwrap();
7463        assert_eq!(
7464            resp.status(),
7465            StatusCode::PAYLOAD_TOO_LARGE,
7466            "a payload over the route's cap but under the framework's was accepted — \
7467             the route's own DefaultBodyLimit layer is not doing anything"
7468        );
7469    }
7470
7471    #[tokio::test]
7472    async fn opml_import_under_limit_upload_is_accepted() {
7473        let state = test_state(&["did:plc:admin"]).await;
7474        let cookie = session_cookie(&state, "did:plc:admin", None);
7475        let db = state.db.clone();
7476        let app = router(state);
7477
7478        // A small, valid OPML well under the cap: must be accepted (the handler
7479        // redirects to `/` or a flash), i.e. never 413.
7480        let opml = br#"<?xml version="1.0"?>
7481<opml version="2.0"><body>
7482  <outline text="Example" type="rss" xmlUrl="https://example.com/feed.xml"/>
7483</body></opml>"#;
7484        let (content_type, body) = opml_multipart(opml);
7485
7486        let resp = app
7487            .oneshot(
7488                Request::builder()
7489                    .method("POST")
7490                    .uri("/opml")
7491                    .header("content-type", content_type)
7492                    .header(header::COOKIE, cookie)
7493                    .body(Body::from(body))
7494                    .unwrap(),
7495            )
7496            .await
7497            .unwrap();
7498        // **Assert it was ACCEPTED, not merely that it was not a 413.**
7499        //
7500        // The old assertion was `assert_ne!(status, PAYLOAD_TOO_LARGE)`, which a
7501        // 500 satisfies — so making `import_opml` fail unconditionally left this
7502        // green. Three other OPML tests caught that mutation; the one whose name
7503        // promises to cover the under-cap case did not.
7504        assert_eq!(
7505            resp.status(),
7506            StatusCode::SEE_OTHER,
7507            "an under-cap OPML upload was not accepted (status {})",
7508            resp.status(),
7509        );
7510        // **303 alone is not acceptance.** `import_opml` redirects on several
7511        // FAILURES too — unparseable OPML, zero feeds found, every feed trimmed
7512        // by a cap — so an import that stored nothing satisfied the status check.
7513        let stored: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM feeds WHERE url = ?1")
7514            .bind("https://example.com/feed.xml")
7515            .fetch_one(&db)
7516            .await
7517            .unwrap();
7518        assert_eq!(stored, 1, "the upload was redirected but imported nothing");
7519        let location = resp
7520            .headers()
7521            .get(header::LOCATION)
7522            .and_then(|v| v.to_str().ok())
7523            .unwrap_or_default()
7524            .to_string();
7525        assert!(
7526            !location.starts_with("/login"),
7527            "the import bounced to login instead of being accepted: {location}",
7528        );
7529    }
7530
7531    #[tokio::test]
7532    async fn opml_import_logged_out_redirects_to_login() {
7533        // Logged-out callers are redirected before the body is consumed; assert
7534        // the auth short-circuit rather than a body-cap rejection.
7535        let state = test_state(&["did:plc:admin"]).await;
7536        let app = router(state);
7537
7538        let opml = b"<opml version=\"2.0\"><body></body></opml>";
7539        let (content_type, body) = opml_multipart(opml);
7540
7541        let resp = app
7542            .oneshot(
7543                Request::builder()
7544                    .method("POST")
7545                    .uri("/opml")
7546                    .header("content-type", content_type)
7547                    .body(Body::from(body))
7548                    .unwrap(),
7549            )
7550            .await
7551            .unwrap();
7552        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
7553        assert_eq!(resp.headers().get(header::LOCATION).unwrap(), "/login");
7554    }
7555
7556    // -- delete-my-data (POST /account/delete) --------------------------------
7557
7558    /// A one-shot mock sidecar: binds a loopback port, answers exactly one
7559    /// `POST /internal/revoke` with `{ok:true,…}`, and reports (via the returned
7560    /// channel) the DID it was asked to revoke. Enough to prove the delete
7561    /// handler triggers the sidecar revoke without pulling in an HTTP-mock crate.
7562    async fn spawn_revoke_sidecar() -> (String, tokio::sync::oneshot::Receiver<String>) {
7563        use tokio::io::{AsyncReadExt, AsyncWriteExt};
7564        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7565        let addr = listener.local_addr().unwrap();
7566        let (tx, rx) = tokio::sync::oneshot::channel::<String>();
7567        tokio::spawn(async move {
7568            let (mut sock, _) = listener.accept().await.unwrap();
7569            let mut buf = vec![0u8; 4096];
7570            let n = sock.read(&mut buf).await.unwrap();
7571            let req = String::from_utf8_lossy(&buf[..n]).to_string();
7572            // Pull the DID out of the JSON body (last line of the request).
7573            let did = req
7574                .split("\r\n\r\n")
7575                .nth(1)
7576                .and_then(|body| {
7577                    let v: serde_json::Value = serde_json::from_str(body.trim()).ok()?;
7578                    v.get("did")?.as_str().map(str::to_string)
7579                })
7580                .unwrap_or_default();
7581            let is_revoke = req.starts_with("POST /internal/revoke");
7582            let body = serde_json::json!({
7583                "ok": true, "did": did, "revoked": true, "hadSession": true
7584            })
7585            .to_string();
7586            let resp = format!(
7587                "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
7588                body.len(),
7589                body
7590            );
7591            sock.write_all(resp.as_bytes()).await.unwrap();
7592            sock.flush().await.unwrap();
7593            let _ = tx.send(if is_revoke { did } else { String::new() });
7594        });
7595        (format!("http://{addr}"), rx)
7596    }
7597
7598    /// Build an [`AppState`] whose sidecar points at `sidecar_url`.
7599    async fn test_state_with_sidecar(allowed: &[&str], sidecar_url: &str) -> AppState {
7600        let defaults = Config::default();
7601        test_state_with_sidecar_and(
7602            allowed,
7603            sidecar_url,
7604            defaults.standard_site,
7605            defaults.max_feeds_global,
7606        )
7607        .await
7608    }
7609
7610    /// [`test_state_with_sidecar`] with the standard.site flag and the global
7611    /// feeds ceiling chosen — the two settings the at:// paths branch on.
7612    async fn test_state_with_sidecar_and(
7613        allowed: &[&str],
7614        sidecar_url: &str,
7615        standard_site: bool,
7616        max_feeds_global: i64,
7617    ) -> AppState {
7618        let db = store::init_url("sqlite::memory:").await.unwrap();
7619        let dids: Vec<String> = allowed.iter().map(|s| s.to_string()).collect();
7620        store::ensure_seed(&db, &dids).await.unwrap();
7621        let mut config = Config {
7622            allowed_dids: dids,
7623            cookie_secret: "test-cookie-secret-000".to_string(),
7624            beta_cap: 3,
7625            standard_site,
7626            max_feeds_global,
7627            ..Config::default()
7628        };
7629        config.sidecar.public_url = sidecar_url.to_string();
7630        config.sidecar.internal_url = sidecar_url.to_string();
7631        AppState::new(config, db).unwrap()
7632    }
7633
7634    /// A confirmed `POST /account/delete` purges the caller's local rows, calls
7635    /// the sidecar revoke for that DID, and clears the session cookie.
7636    #[tokio::test]
7637    async fn account_delete_purges_rows_and_triggers_revoke() {
7638        let (sidecar_url, revoke_rx) = spawn_revoke_sidecar().await;
7639        let did = "did:plc:leaver";
7640        let state = test_state_with_sidecar(&[], &sidecar_url).await;
7641
7642        // Seed the DID with local rows across the per-DID tables.
7643        store::grant_access(&state.db, did, Some("leaver.example"), "test", None)
7644            .await
7645            .unwrap();
7646        store::replace_sub_refs(&state.db, did, &[]).await.unwrap();
7647        store::mint_code(&state.db, did, 3600).await.unwrap();
7648        assert!(store::has_beta_access(&state.db, did).await.unwrap());
7649
7650        let cookie = session_cookie(&state, did, Some("leaver.example"));
7651        let app = router(state.clone());
7652
7653        let resp = app
7654            .oneshot(
7655                Request::builder()
7656                    .method("POST")
7657                    .uri("/account/delete")
7658                    .header(header::COOKIE, cookie)
7659                    .header("content-type", "application/x-www-form-urlencoded")
7660                    .body(Body::from("confirm=DELETE"))
7661                    .unwrap(),
7662            )
7663            .await
7664            .unwrap();
7665
7666        // Signed out: redirect to /login with the cookie cleared.
7667        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
7668        assert!(resp
7669            .headers()
7670            .get(header::LOCATION)
7671            .unwrap()
7672            .to_str()
7673            .unwrap()
7674            .starts_with("/login"));
7675        let set_cookie = resp
7676            .headers()
7677            .get(header::SET_COOKIE)
7678            .unwrap()
7679            .to_str()
7680            .unwrap();
7681        assert!(set_cookie.contains("Max-Age=0"), "cookie must be cleared");
7682
7683        // The sidecar revoke was called for exactly this DID.
7684        //
7685        // BOUNDED. A bare `await` here meant a broken `revoke_everywhere` — one
7686        // that simply never called the sidecar — hung this test forever instead
7687        // of failing it: a wedged CI job rather than a red one, which is the
7688        // worse of the two signals because nobody reads it as a defect.
7689        let revoked_did = tokio::time::timeout(std::time::Duration::from_secs(10), revoke_rx)
7690            .await
7691            .expect("the sidecar revoke never fired; revoke_everywhere did not call it")
7692            .unwrap();
7693        assert_eq!(
7694            revoked_did, did,
7695            "sidecar revoke must fire for the caller DID"
7696        );
7697
7698        // Local rows are gone.
7699        assert!(!store::has_beta_access(&state.db, did).await.unwrap());
7700        let codes: i64 =
7701            sqlx::query_scalar("SELECT COUNT(*) FROM invite_codes WHERE creator_did = ?1")
7702                .bind(did)
7703                .fetch_one(&state.db)
7704                .await
7705                .unwrap();
7706        assert_eq!(codes, 0);
7707    }
7708
7709    /// An UN-confirmed `POST /account/delete` (wrong/blank `confirm`) deletes
7710    /// nothing and bounces back to /manage.
7711    #[tokio::test]
7712    async fn account_delete_without_confirm_is_a_noop() {
7713        let did = "did:plc:staying";
7714        let state = test_state(&[]).await;
7715        store::grant_access(&state.db, did, None, "test", None)
7716            .await
7717            .unwrap();
7718        let cookie = session_cookie(&state, did, None);
7719        let app = router(state.clone());
7720
7721        let resp = app
7722            .oneshot(
7723                Request::builder()
7724                    .method("POST")
7725                    .uri("/account/delete")
7726                    .header(header::COOKIE, cookie)
7727                    .header("content-type", "application/x-www-form-urlencoded")
7728                    .body(Body::from("confirm=nope"))
7729                    .unwrap(),
7730            )
7731            .await
7732            .unwrap();
7733
7734        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
7735        assert!(resp
7736            .headers()
7737            .get(header::LOCATION)
7738            .unwrap()
7739            .to_str()
7740            .unwrap()
7741            .starts_with("/manage"));
7742        // Nothing deleted.
7743        assert!(store::has_beta_access(&state.db, did).await.unwrap());
7744    }
7745
7746    /// PDS-outage authorization: when the sidecar is unreachable (as it is in
7747    /// this harness — the default sidecar URL is not served), a DID must STILL
7748    /// be unable to read or mutate an entry in a feed it does not subscribe to.
7749    /// This guards the `resolve_subscriptions` fallback: it must fail CLOSED
7750    /// (serve only the DID's own `sub_ref`), never widen the caller's surface to
7751    /// every cached feed.
7752    #[tokio::test]
7753    async fn pds_outage_does_not_widen_cross_did_access() {
7754        let did_a = "did:plc:aaaa";
7755        let state = test_state(&[]).await;
7756        store::grant_access(&state.db, did_a, None, "test", None)
7757            .await
7758            .unwrap();
7759
7760        // Shared cache: feed_a (A subscribes) + feed_b (A does NOT). An entry
7761        // lives in feed_b — the one A must never touch during the outage.
7762        let feed_a = store::upsert_feed(
7763            &state.db,
7764            &store::NewFeed {
7765                url: "https://a.example/feed.xml".to_string(),
7766                title: Some("A".to_string()),
7767                ..Default::default()
7768            },
7769        )
7770        .await
7771        .unwrap();
7772        let feed_b = store::upsert_feed(
7773            &state.db,
7774            &store::NewFeed {
7775                url: "https://b.example/feed.xml".to_string(),
7776                title: Some("B".to_string()),
7777                ..Default::default()
7778            },
7779        )
7780        .await
7781        .unwrap();
7782        store::insert_entries(
7783            &state.db,
7784            feed_b,
7785            &[store::NewEntry {
7786                guid: "b-1".to_string(),
7787                url: Some("https://b.example/1".to_string()),
7788                title: Some("B one".to_string()),
7789                published: Some("2026-07-11T00:00:00Z".to_string()),
7790                content_html: Some("<p>secret B body</p>".to_string()),
7791                ..Default::default()
7792            }],
7793            0,
7794        )
7795        .await
7796        .unwrap();
7797        // A subscribes ONLY to feed_a.
7798        store::replace_sub_refs(&state.db, did_a, &[feed_a])
7799            .await
7800            .unwrap();
7801        // Read B's entry id via a transient sub_ref, then drop it so only the
7802        // shared cache holds B's entry (no DID subscribes to feed_b anymore).
7803        store::replace_sub_refs(&state.db, "did:plc:bbbb", &[feed_b])
7804            .await
7805            .unwrap();
7806        let b_entry_id = store::entries_for_feed(&state.db, "did:plc:bbbb", feed_b)
7807            .await
7808            .unwrap()[0]
7809            .id;
7810        store::replace_sub_refs(&state.db, "did:plc:bbbb", &[])
7811            .await
7812            .unwrap();
7813
7814        let cookie = session_cookie(&state, did_a, None);
7815        let app = router(state.clone());
7816
7817        // GET /entries/{b} as A → 404 even during the outage.
7818        let get_b = app
7819            .clone()
7820            .oneshot(
7821                Request::builder()
7822                    .method("GET")
7823                    .uri(format!("/entries/{b_entry_id}"))
7824                    .header(header::COOKIE, cookie.clone())
7825                    .body(Body::empty())
7826                    .unwrap(),
7827            )
7828            .await
7829            .unwrap();
7830        assert_eq!(
7831            get_b.status(),
7832            StatusCode::NOT_FOUND,
7833            "A must not read B's entry during a PDS outage"
7834        );
7835
7836        // POST /entries/{b}/read as A → 404, and no entry_state row is written.
7837        let read_b = app
7838            .oneshot(
7839                Request::builder()
7840                    .method("POST")
7841                    .uri(format!("/entries/{b_entry_id}/read"))
7842                    .header(header::COOKIE, cookie)
7843                    .header("content-type", "application/x-www-form-urlencoded")
7844                    .body(Body::from("read=true"))
7845                    .unwrap(),
7846            )
7847            .await
7848            .unwrap();
7849        assert_eq!(
7850            read_b.status(),
7851            StatusCode::NOT_FOUND,
7852            "A must not mark B's entry read during a PDS outage"
7853        );
7854
7855        // The fallback must NOT have widened A's sub_ref to feed_b.
7856        let a_feed_ids: Vec<i64> = sqlx::query_scalar("SELECT feed_id FROM sub_ref WHERE did = ?1")
7857            .bind(did_a)
7858            .fetch_all(&state.db)
7859            .await
7860            .unwrap();
7861        assert_eq!(
7862            a_feed_ids,
7863            vec![feed_a],
7864            "outage fallback must not add feeds A never subscribed to"
7865        );
7866        // And B's entry has zero read-state (A's attempt did not mutate).
7867        let es_count: i64 =
7868            sqlx::query_scalar("SELECT COUNT(*) FROM entry_state WHERE did = ?1 AND entry_id = ?2")
7869                .bind(did_a)
7870                .bind(b_entry_id)
7871                .fetch_one(&state.db)
7872                .await
7873                .unwrap();
7874        assert_eq!(es_count, 0, "no cross-DID mutation during the outage");
7875    }
7876
7877    /// **A logout with nothing to revoke is a SUCCESS — on the arm that had
7878    /// nothing. The other arm is counted separately.**
7879    ///
7880    /// Logout is idempotent, so `NoSession` must record as ok; counting it as an
7881    /// error would make the metric noisy in exactly the case that is fine.
7882    ///
7883    /// But `revoke_everywhere` has TWO arms, and a review found that counting
7884    /// only the rust one let `oauth_revoke` report all-clear while every sidecar
7885    /// revocation failed. For anyone who logged in before the cutover the sidecar
7886    /// store is the only one holding tokens, so the rust arm correctly says
7887    /// NoSession and the metric said nothing was wrong. Both arms are now
7888    /// recorded, distinguished by the backend column — so this test pins the
7889    /// BACKEND as well as the outcome.
7890    #[tokio::test]
7891    async fn a_logout_with_no_session_counts_as_success() {
7892        let did = "did:plc:aaaa";
7893        let state = test_state(&[]).await;
7894        assert!(
7895            state.oauth.is_some(),
7896            "meaningless without an oauth runtime; the revoke arm would be skipped",
7897        );
7898
7899        revoke_everywhere(&state, did).await;
7900        let rows = state.metrics.snapshot();
7901        let find = |b: crate::metrics::Backend| {
7902            rows.iter()
7903                .find(|r| r.op == "oauth_revoke" && r.backend == b)
7904                .unwrap_or_else(|| panic!("no oauth_revoke row for {b:?}"))
7905        };
7906
7907        // Rust arm: nothing stored for this DID, so NoSession -> ok.
7908        let rust = find(crate::metrics::Backend::Rust);
7909        assert_eq!(
7910            rust.stats.err_count, 0,
7911            "NoSession was counted as a failure; logout is idempotent",
7912        );
7913        assert_eq!(rust.stats.ok_count, 1);
7914
7915        // Sidecar arm: unreachable in a test, so it must be recorded as an
7916        // ERROR under its own backend — not silently dropped, and not folded
7917        // into the rust row.
7918        let sidecar = find(crate::metrics::Backend::Sidecar);
7919        assert_eq!(
7920            sidecar.stats.err_count, 1,
7921            "a failed sidecar revoke was not counted",
7922        );
7923    }
7924
7925    /// **`Failed` must count as an error — the half the metric exists for.**
7926    ///
7927    /// A review found this unpinned: replacing the mapping with
7928    /// `let revoke_ok = true;` passed all 682 tests. The only revoke test
7929    /// asserted the `NoSession -> ok` half, so the branch that actually means
7930    /// "the PDS still holds tokens we asked it to drop" was untested.
7931    ///
7932    /// Driven through the same handler, with a session present but the PDS
7933    /// unreachable, so `sign_out_discovering` returns `Failed`.
7934    #[tokio::test]
7935    async fn a_failed_rust_revoke_counts_as_an_error() {
7936        let did = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
7937        let state = test_state(&[]).await;
7938        let runtime = state.oauth.as_deref().expect("oauth runtime");
7939        crate::oauth::store::put_session(
7940            &state.db,
7941            &runtime.codec,
7942            &crate::oauth::store::OAuthSession {
7943                sub: did.into(),
7944                issuer: "https://auth.invalid".into(),
7945                aud: "https://pds.invalid".into(),
7946                dpop_key_jwk: crate::oauth::keys::SigningKey::generate("session-dpop")
7947                    .to_jwk_json()
7948                    .unwrap(),
7949                access_token: "at".into(),
7950                refresh_token: "rt".into(),
7951                token_type: "DPoP".into(),
7952                granted_scope: "atproto".into(),
7953                expires_at: Some(crate::store::now_unix() + 3600),
7954            },
7955        )
7956        .await
7957        .unwrap();
7958
7959        revoke_everywhere(&state, did).await;
7960
7961        let rows = state.metrics.snapshot();
7962        let rust = rows
7963            .iter()
7964            .find(|r| r.op == "oauth_revoke" && r.backend == crate::metrics::Backend::Rust)
7965            .expect("no rust oauth_revoke row");
7966        assert_eq!(
7967            rust.stats.err_count, 1,
7968            "an unreachable PDS must count as a revocation failure",
7969        );
7970        assert_eq!(rust.stats.ok_count, 0);
7971    }
7972
7973    /// **The `href` defence is now carried by the TYPE, not by remembering.**
7974    ///
7975    /// `EntryRow.link` used to be a `String`, and the guard was "call
7976    /// `net::safe_link` before assigning it". Deleting that call left all 679
7977    /// tests passing — a live XSS defence with nothing protecting it.
7978    ///
7979    /// `SafeLink` has no `From<String>` and no public member, so the only way to
7980    /// get foreign input into an `href` is `external`, which does the check
7981    /// itself. This test pins that constructor; the *wiring* is now pinned by
7982    /// the compiler, which is the part a test could never hold down.
7983    ///
7984    /// Note the empty-not-absent behaviour: a rejected URL yields an EMPTY link
7985    /// so the template renders the row WITHOUT an anchor. Dropping the row
7986    /// instead would make the record unremovable, because the un-save button
7987    /// lives on it.
7988    #[test]
7989    fn a_hostile_scheme_cannot_reach_an_href_through_safelink() {
7990        for hostile in [
7991            "javascript:alert(1)",
7992            "JavaScript:alert(1)",
7993            "  javascript:alert(1)",
7994            "data:text/html;base64,PHNjcmlwdD4=",
7995            "vbscript:msgbox(1)",
7996            "file:///etc/passwd",
7997            // Protocol-relative: inherits the page's scheme, so it is an
7998            // off-site link wearing a same-site costume. Carried over from the
7999            // test this one replaces, which was its only unique input.
8000            "//evil.example/path",
8001        ] {
8002            let link = SafeLink::external(hostile);
8003            assert!(
8004                link.is_empty(),
8005                "{hostile:?} produced a non-empty href: {link}",
8006            );
8007            assert!(
8008                !link.to_string().to_ascii_lowercase().contains("script"),
8009                "{hostile:?} leaked into the rendered link",
8010            );
8011        }
8012
8013        // And the other direction: a check that rejects everything would satisfy
8014        // the loop above while breaking every real saved record.
8015        for good in ["https://example.com/a?b=c#d", "http://example.com/"] {
8016            let link = SafeLink::external(good);
8017            assert!(!link.is_empty(), "{good:?} was wrongly rejected");
8018            assert_eq!(link.to_string(), good);
8019        }
8020    }
8021
8022    /// **The WIRING, not the helper — this is the one that catches the real
8023    /// mistake.**
8024    ///
8025    /// `a_hostile_scheme_cannot_reach_an_href_through_safelink` pins what
8026    /// `SafeLink::external` *does*. It cannot pin that the saved-record path
8027    /// *calls* it, and a review proved that gap was live twice over: swapping
8028    /// `external` for the app-path constructor, and constructing the tuple
8029    /// directly, both restored the whole `javascript:` hole with every test
8030    /// green. The type now blocks both — `entry` takes an `i64`, and the field
8031    /// lives in another module — but the wiring deserves a test of its own
8032    /// rather than resting on the shape of a signature.
8033    ///
8034    /// Renders the actual row through the actual handler, from a record whose
8035    /// URL is hostile.
8036    #[tokio::test]
8037    async fn a_saved_record_with_a_hostile_url_renders_no_anchor() {
8038        let did = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
8039        let sidecar = spawn_saved_sidecar("javascript:alert(1)", "Hostile record").await;
8040        let mut state = test_state_with_sidecar(&[did], &sidecar).await;
8041        std::sync::Arc::get_mut(&mut state.config).unwrap().dev_did = Some(did.to_string());
8042
8043        let resp = router(state)
8044            .oneshot(
8045                Request::builder()
8046                    .uri("/?view=starred")
8047                    .body(Body::empty())
8048                    .unwrap(),
8049            )
8050            .await
8051            .unwrap();
8052        assert_eq!(resp.status(), StatusCode::OK);
8053        let body = String::from_utf8(
8054            axum::body::to_bytes(resp.into_body(), usize::MAX)
8055                .await
8056                .unwrap()
8057                .to_vec(),
8058        )
8059        .unwrap();
8060
8061        // Not in an href, and not as the title either — the title falls back to
8062        // the URL for links we DO render, so both paths must withhold it.
8063        assert!(
8064            !body.to_ascii_lowercase().contains("javascript:"),
8065            "the hostile scheme reached the rendered page",
8066        );
8067        // But the row must survive: the un-save button lives on it, so dropping
8068        // the row would make the record unremovable from here.
8069        assert!(
8070            body.contains("unusable link"),
8071            "the row was dropped instead of rendering without an anchor",
8072        );
8073    }
8074
8075    /// **The reader view's two `href`s, through the actual handler.**
8076    ///
8077    /// The sibling above covers the LIST row. `entry.html` has its own pair of
8078    /// `href`s fed by `EntryTemplate.url`, and they were a raw `Option<String>`
8079    /// taken straight off the `entries.url` column — a remote feed's `<link>`.
8080    ///
8081    /// Ingest already scheme-checks that column (`feed.rs`'s `entry_link`), so
8082    /// this was never a live hole. But that guard is procedural and sits a long
8083    /// way from the `href`: it holds only as long as every future writer to
8084    /// `entries.url` remembers to go through `feed.rs`. This test does not
8085    /// depend on it — it writes the hostile URL into the column DIRECTLY, which
8086    /// is precisely the state the ingest check cannot speak for.
8087    ///
8088    /// **Both directions, deliberately.** A fix that renders no link at all
8089    /// satisfies every negative assertion here, and would break every real
8090    /// entry. The second half is what makes the first half mean something.
8091    #[tokio::test]
8092    async fn a_hostile_entry_url_renders_the_reader_without_an_original_link() {
8093        let did = "did:plc:readerhref";
8094        let state = test_state(&[]).await;
8095        store::grant_access(&state.db, did, None, "test", None)
8096            .await
8097            .unwrap();
8098        let feed = store::upsert_feed(
8099            &state.db,
8100            &store::NewFeed {
8101                url: "https://href.example/feed.xml".to_string(),
8102                title: Some("Href".to_string()),
8103                ..Default::default()
8104            },
8105        )
8106        .await
8107        .unwrap();
8108        // Straight into the column, bypassing `feed.rs` — the whole point.
8109        store::insert_entries(
8110            &state.db,
8111            feed,
8112            &[
8113                store::NewEntry {
8114                    guid: "hostile-1".to_string(),
8115                    url: Some("javascript:alert(1)".to_string()),
8116                    title: Some("Hostile entry".to_string()),
8117                    published: Some("2026-07-11T00:00:00Z".to_string()),
8118                    ..Default::default()
8119                },
8120                store::NewEntry {
8121                    guid: "benign-1".to_string(),
8122                    url: Some("https://href.example/post".to_string()),
8123                    title: Some("Benign entry".to_string()),
8124                    published: Some("2026-07-10T00:00:00Z".to_string()),
8125                    ..Default::default()
8126                },
8127            ],
8128            0,
8129        )
8130        .await
8131        .unwrap();
8132        store::replace_sub_refs(&state.db, did, &[feed])
8133            .await
8134            .unwrap();
8135        let rows = store::entries_for_feed(&state.db, did, feed).await.unwrap();
8136        let id_of = |guid: &str| {
8137            rows.iter()
8138                .find(|r| r.guid == guid)
8139                .unwrap_or_else(|| panic!("{guid} was not inserted"))
8140                .id
8141        };
8142
8143        let cookie = session_cookie(&state, did, None);
8144        let app = router(state.clone());
8145
8146        let render = |id: i64| {
8147            let app = app.clone();
8148            let cookie = cookie.clone();
8149            async move {
8150                let resp = app
8151                    .oneshot(
8152                        Request::builder()
8153                            .method("GET")
8154                            .uri(format!("/entries/{id}"))
8155                            .header(header::COOKIE, cookie)
8156                            .body(Body::empty())
8157                            .unwrap(),
8158                    )
8159                    .await
8160                    .unwrap();
8161                assert_eq!(resp.status(), StatusCode::OK);
8162                String::from_utf8(
8163                    axum::body::to_bytes(resp.into_body(), usize::MAX)
8164                        .await
8165                        .unwrap()
8166                        .to_vec(),
8167                )
8168                .unwrap()
8169            }
8170        };
8171
8172        let hostile = render(id_of("hostile-1")).await;
8173        // The reader page for THIS entry actually rendered. Without this the
8174        // three negatives below are satisfied by an empty body.
8175        assert!(
8176            hostile.contains("Hostile entry"),
8177            "the reader did not render the entry: {hostile}",
8178        );
8179        assert!(
8180            !hostile.to_ascii_lowercase().contains("javascript:"),
8181            "the hostile scheme reached the reader page: {hostile}",
8182        );
8183        // Not merely escaped — the template took its no-link branch. Both
8184        // `href`s are gated on the same `Option`, so this covers the byline
8185        // link and the action-bar button together.
8186        assert!(
8187            !hostile.contains("actionbar-open"),
8188            "the action bar rendered an open-original link for a refused URL: {hostile}",
8189        );
8190        assert!(
8191            !hostile.contains("Original \u{2197}"),
8192            "the byline rendered an original link for a refused URL: {hostile}",
8193        );
8194
8195        // The other direction: a legitimate entry still links out, so "render
8196        // nothing" cannot pass as a fix.
8197        let benign = render(id_of("benign-1")).await;
8198        assert!(
8199            benign.contains("Benign entry"),
8200            "the reader did not render the benign entry: {benign}",
8201        );
8202        // BOTH `href`s, counted. The negatives above fire on the action bar
8203        // first, so without this the byline needle `Original \u{2197}` is never
8204        // once observed failing — a misspelled needle would pass forever.
8205        assert_eq!(
8206            benign
8207                .matches(r#"href="https://href.example/post""#)
8208                .count(),
8209            2,
8210            "entry.html has two `href`s for the entry URL — the byline link and \
8211             the action-bar button — and this render produced a different \
8212             number: {benign}",
8213        );
8214        assert!(
8215            benign.contains("actionbar-open"),
8216            "a legitimate entry lost its open-original button: {benign}",
8217        );
8218        assert!(
8219            benign.contains("Original \u{2197}"),
8220            "a legitimate entry lost its byline link: {benign}",
8221        );
8222    }
8223
8224    /// **The outage fallback must not widen what the caller can READ — and the
8225    /// sibling test above can only see what it WRITES.**
8226    ///
8227    /// `pds_outage_does_not_widen_cross_did_access` asserts on `sub_ref` rows and
8228    /// on `entry_state`: the fallback's side effects. But the fail-open it names
8229    /// returns **early**, before `sync_sub_refs` runs, so it touches neither. It
8230    /// leaks through the list it *hands back* — the sidebar and the reader render
8231    /// from that list, so a caller sees another DID's feeds while `sub_ref` stays
8232    /// perfectly honest and every existing assertion stays green.
8233    ///
8234    /// Measured, not assumed: replacing `feeds_for_did(pool, did)` with
8235    /// `due_feeds(pool, "9999-…", 10_000)` over the whole shared cache — the
8236    /// exact historical bug the fallback's comment describes — left **all 663
8237    /// tests passing**. Cross-tenant isolation is the one property this project
8238    /// cannot regress quietly, and nothing observed it.
8239    ///
8240    /// So this asserts on the RETURN VALUE, which is the thing that reaches the
8241    /// user, and it deliberately does not look at `sub_ref` at all — that half is
8242    /// already covered above.
8243    #[tokio::test]
8244    async fn the_outage_fallback_returns_only_the_callers_own_feeds() {
8245        let did_a = "did:plc:aaaa";
8246        let state = test_state(&[]).await;
8247        store::grant_access(&state.db, did_a, None, "test", None)
8248            .await
8249            .unwrap();
8250
8251        let feed_a = store::upsert_feed(
8252            &state.db,
8253            &store::NewFeed {
8254                url: "https://a.example/feed.xml".to_string(),
8255                title: Some("A".to_string()),
8256                ..Default::default()
8257            },
8258        )
8259        .await
8260        .unwrap();
8261        let _feed_b = store::upsert_feed(
8262            &state.db,
8263            &store::NewFeed {
8264                url: "https://b.example/feed.xml".to_string(),
8265                title: Some("B".to_string()),
8266                ..Default::default()
8267            },
8268        )
8269        .await
8270        .unwrap();
8271        // A subscribes ONLY to feed_a. feed_b is in the shared cache and belongs
8272        // to nobody — exactly the row a whole-cache fallback would hand to A.
8273        store::replace_sub_refs(&state.db, did_a, &[feed_a])
8274            .await
8275            .unwrap();
8276
8277        // No sidecar and no PDS are reachable from a test, so
8278        // `list_subscriptions_sorted` fails and this IS the outage path. Assert
8279        // that, rather than assuming it: if the repo ever starts succeeding here,
8280        // this test would silently stop exercising the fallback at all.
8281        assert!(
8282            state.repo().list_subscriptions_sorted(did_a).await.is_err(),
8283            "this test is only meaningful on the outage path; the repo answered",
8284        );
8285
8286        let resolved = resolve_subscriptions(&state, did_a).await;
8287
8288        let urls: Vec<&str> = resolved.iter().map(|r| r.sub.url.as_str()).collect();
8289        assert_eq!(
8290            urls,
8291            vec!["https://a.example/feed.xml"],
8292            "the outage fallback must return the caller's OWN subscriptions only; \
8293             any other feed here is cross-tenant read access granted by an outage",
8294        );
8295    }
8296
8297    /// Build an [`AppState`] over a fresh in-memory DB with explicit feed caps,
8298    /// seeding `did` a beta seat + session-capable state.
8299    async fn test_state_with_caps(
8300        did: &str,
8301        max_subs_per_did: i64,
8302        max_feeds_global: i64,
8303    ) -> AppState {
8304        let db = store::init_url("sqlite::memory:").await.unwrap();
8305        let config = Config {
8306            cookie_secret: "test-cookie-secret-000".to_string(),
8307            beta_cap: 100,
8308            max_subs_per_did,
8309            max_feeds_global,
8310            ..Config::default()
8311        };
8312        store::grant_access(&db, did, None, "test", None)
8313            .await
8314            .unwrap();
8315        AppState::new(config, db).unwrap()
8316    }
8317
8318    /// An OPML document with `n` distinct public feeds.
8319    fn opml_with_feeds(n: usize) -> String {
8320        let mut outlines = String::new();
8321        for i in 0..n {
8322            outlines.push_str(&format!(
8323                "<outline type=\"rss\" text=\"F{i}\" xmlUrl=\"https://f{i}.example/feed.xml\"/>\n"
8324            ));
8325        }
8326        format!(
8327            "<?xml version=\"1.0\"?>\n<opml version=\"2.0\"><head><title>t</title></head><body>\n{outlines}</body></opml>"
8328        )
8329    }
8330
8331    /// OPML bulk import must honour the GLOBAL feeds ceiling: importing more
8332    /// distinct new feeds than the shared cache can hold caches only up to the
8333    /// ceiling — the rest are trimmed. (Regression: the import loop previously
8334    /// bypassed `max_feeds_global` entirely.)
8335    #[tokio::test]
8336    async fn opml_import_enforces_global_feeds_ceiling() {
8337        let did = "did:plc:importer";
8338        // Cap the shared cache at 3 feeds; import 10 distinct new ones.
8339        let state = test_state_with_caps(did, 0, 3).await;
8340        let cookie = session_cookie(&state, did, None);
8341        let (ct, body) = opml_multipart(opml_with_feeds(10).as_bytes());
8342        let app = router(state.clone());
8343
8344        let resp = app
8345            .oneshot(
8346                Request::builder()
8347                    .method("POST")
8348                    .uri("/opml")
8349                    .header(header::COOKIE, cookie)
8350                    .header("content-type", ct)
8351                    .body(Body::from(body))
8352                    .unwrap(),
8353            )
8354            .await
8355            .unwrap();
8356        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8357
8358        let feeds = store::count_feeds(&state.db).await.unwrap();
8359        assert!(
8360            feeds <= 3,
8361            "OPML import blew past the global ceiling: {feeds} feeds cached with cap=3"
8362        );
8363    }
8364
8365    /// **A malformed `at://` on the add path is "not a kind of feed we take",
8366    /// not "private/paid".** The first gate was the privacy classifier, whose
8367    /// at:// arm fails closed as `Private` for anything not a well-formed
8368    /// publication URI — so a typo (`did:plc:TOOSHORT`, a missing rkey) drew
8369    /// the private-feed flash and a "refused private/paid feed" log line. On
8370    /// main the same input reached `resolve_feed_url` and got "Couldn't find a
8371    /// feed". Storability is decided first for an at:// input, with its own
8372    /// message.
8373    #[tokio::test]
8374    async fn a_malformed_at_uri_on_the_add_path_is_refused_as_unsupported_not_private() {
8375        let did = "did:plc:typoist";
8376        let state = test_state_with_caps(did, 0, 0).await;
8377        let cookie = session_cookie(&state, did, None);
8378        for input in [
8379            "at%3A%2F%2Falice.example.com%2Fsite.standard.publication",
8380            "at%3A%2F%2Fdid%3Aplc%3ATOOSHORT%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
8381        ] {
8382            let resp = router(state.clone())
8383                .oneshot(
8384                    Request::builder()
8385                        .method("POST")
8386                        .uri("/subscriptions")
8387                        .header(header::COOKIE, cookie.clone())
8388                        .header("content-type", "application/x-www-form-urlencoded")
8389                        .body(Body::from(format!("url={input}")))
8390                        .unwrap(),
8391                )
8392                .await
8393                .unwrap();
8394            assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8395            let loc = resp
8396                .headers()
8397                .get(header::LOCATION)
8398                .unwrap()
8399                .to_str()
8400                .unwrap();
8401            assert!(
8402                loc.contains("kind%20of%20feed"),
8403                "expected the unsupported-feed flash for {input}, got {loc}"
8404            );
8405            assert!(
8406                !loc.contains("Private"),
8407                "a storability refusal was reported as a privacy one for {input}: {loc}"
8408            );
8409        }
8410        assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
8411    }
8412
8413    /// **An OPML entry this instance cannot store is counted and reported, not
8414    /// silently dropped.** The storability `continue` incremented nothing,
8415    /// while the privacy branch beside it produced a user-visible label — so
8416    /// an OPML exported from a standard.site-enabled instance imported
8417    /// "successfully" with entries missing and no reason given. The reader is
8418    /// told how many, and why.
8419    #[tokio::test]
8420    async fn opml_import_reports_entries_this_instance_cannot_store() {
8421        let did = "did:plc:renamer4";
8422        let (sidecar, _puts) = spawn_rename_sidecar(seeded_subscription()).await;
8423        let state = test_state_with_sidecar(&[did], &sidecar).await;
8424        assert!(!state.config.standard_site);
8425        let opml = format!(
8426            "<?xml version=\"1.0\"?>\n<opml version=\"2.0\"><head><title>t</title></head><body>\n\
8427             <outline type=\"rss\" text=\"Real\" xmlUrl=\"https://real.example/feed.xml\"/>\n\
8428             <outline type=\"rss\" text=\"Pub\" xmlUrl=\"{AT_URI_SUB}\"/>\n\
8429             </body></opml>"
8430        );
8431        let (ct, body) = opml_multipart(opml.as_bytes());
8432        let cookie = session_cookie(&state, did, None);
8433        let resp = router(state.clone())
8434            .oneshot(
8435                Request::builder()
8436                    .method("POST")
8437                    .uri("/opml")
8438                    .header(header::COOKIE, cookie)
8439                    .header("content-type", ct)
8440                    .body(Body::from(body))
8441                    .unwrap(),
8442            )
8443            .await
8444            .unwrap();
8445        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8446        let loc = resp
8447            .headers()
8448            .get(header::LOCATION)
8449            .unwrap()
8450            .to_str()
8451            .unwrap();
8452        assert!(
8453            loc.contains("Imported%201%20feed"),
8454            "unexpected flash: {loc}"
8455        );
8456        assert!(
8457            loc.contains("1%20feed%28s%29%20skipped") && loc.contains("can%20subscribe%20to"),
8458            "the dropped entry was not reported: {loc}"
8459        );
8460        // Reported by count only: the at-URI itself is not echoed back.
8461        assert!(
8462            !loc.contains("site.standard.publication"),
8463            "the URI was echoed: {loc}"
8464        );
8465    }
8466
8467    /// OPML bulk import must honour the PER-DID subscription cap: a DID at its
8468    /// cap imports zero new feeds.
8469    #[tokio::test]
8470    async fn opml_import_enforces_per_did_cap() {
8471        let did = "did:plc:capped";
8472        // Per-DID cap 2, global unlimited. Pre-seed the DID at its cap.
8473        let state = test_state_with_caps(did, 2, 0).await;
8474        let existing_a = store::upsert_feed(
8475            &state.db,
8476            &store::NewFeed {
8477                url: "https://have-a.example/feed.xml".to_string(),
8478                ..Default::default()
8479            },
8480        )
8481        .await
8482        .unwrap();
8483        let existing_b = store::upsert_feed(
8484            &state.db,
8485            &store::NewFeed {
8486                url: "https://have-b.example/feed.xml".to_string(),
8487                ..Default::default()
8488            },
8489        )
8490        .await
8491        .unwrap();
8492        store::replace_sub_refs(&state.db, did, &[existing_a, existing_b])
8493            .await
8494            .unwrap();
8495        let before = store::count_feeds(&state.db).await.unwrap();
8496
8497        let cookie = session_cookie(&state, did, None);
8498        let (ct, body) = opml_multipart(opml_with_feeds(10).as_bytes());
8499        let app = router(state.clone());
8500        let resp = app
8501            .oneshot(
8502                Request::builder()
8503                    .method("POST")
8504                    .uri("/opml")
8505                    .header(header::COOKIE, cookie)
8506                    .header("content-type", ct)
8507                    .body(Body::from(body))
8508                    .unwrap(),
8509            )
8510            .await
8511            .unwrap();
8512        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8513        // Headroom was 0 → no new feeds imported into the shared cache.
8514        let after = store::count_feeds(&state.db).await.unwrap();
8515        assert_eq!(after, before, "over-cap DID imported new feeds anyway");
8516    }
8517
8518    /// Single-add per-DID cap: a DID at its subscription cap is refused before
8519    /// any fetch, with the limit flash.
8520    #[tokio::test]
8521    async fn single_add_enforces_per_did_cap() {
8522        let did = "did:plc:subcapped";
8523        let state = test_state_with_caps(did, 1, 0).await;
8524        let f = store::upsert_feed(
8525            &state.db,
8526            &store::NewFeed {
8527                url: "https://have.example/feed.xml".to_string(),
8528                ..Default::default()
8529            },
8530        )
8531        .await
8532        .unwrap();
8533        store::replace_sub_refs(&state.db, did, &[f]).await.unwrap();
8534        let cookie = session_cookie(&state, did, None);
8535        let app = router(state.clone());
8536        let resp = app
8537            .oneshot(
8538                Request::builder()
8539                    .method("POST")
8540                    .uri("/subscriptions")
8541                    .header(header::COOKIE, cookie)
8542                    .header("content-type", "application/x-www-form-urlencoded")
8543                    .body(Body::from("url=https://another.example/feed.xml"))
8544                    .unwrap(),
8545            )
8546            .await
8547            .unwrap();
8548        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8549        let loc = resp
8550            .headers()
8551            .get(header::LOCATION)
8552            .unwrap()
8553            .to_str()
8554            .unwrap();
8555        assert!(
8556            loc.contains("Subscription%20limit%20reached"),
8557            "expected sub-limit flash, got {loc}"
8558        );
8559    }
8560
8561    /// `GET /` renders at most one page of rows and offers a way to the rest.
8562    ///
8563    /// The handler used to materialize EVERY unread entry — `SELECT e.*`, no
8564    /// `LIMIT`, article bodies included — and hand the lot to the template. With
8565    /// 250 entries that is the whole list in one response; with a real backlog on
8566    /// a 512 MB box it is the OOM the operator review flagged. Asserts the page
8567    /// is capped, the heading still reports the true total, and page 2 is
8568    /// reachable and disjoint.
8569    #[tokio::test]
8570    async fn the_reader_index_pages_instead_of_rendering_everything() {
8571        let did = "did:plc:pager";
8572        let state = test_state(&[]).await;
8573        store::grant_access(&state.db, did, None, "test", None)
8574            .await
8575            .unwrap();
8576        let feed = store::upsert_feed(
8577            &state.db,
8578            &store::NewFeed {
8579                url: "https://pager.example/feed.xml".to_string(),
8580                title: Some("Pager".to_string()),
8581                ..Default::default()
8582            },
8583        )
8584        .await
8585        .unwrap();
8586        let total = 250_usize;
8587        let entries: Vec<store::NewEntry> = (0..total)
8588            .map(|i| store::NewEntry {
8589                guid: format!("p-{i:04}"),
8590                url: Some(format!("https://pager.example/{i}")),
8591                title: Some(format!("Article {i:04}")),
8592                published: Some(format!("2026-07-{:02}T00:00:00Z", (i % 28) + 1)),
8593                content_html: Some("x".repeat(4_000)),
8594                ..Default::default()
8595            })
8596            .collect();
8597        store::insert_entries(&state.db, feed, &entries, 0)
8598            .await
8599            .unwrap();
8600        store::replace_sub_refs(&state.db, did, &[feed])
8601            .await
8602            .unwrap();
8603
8604        let cookie = session_cookie(&state, did, None);
8605        let app = router(state.clone());
8606        let get = |uri: &str| {
8607            let app = app.clone();
8608            let cookie = cookie.clone();
8609            let uri = uri.to_string();
8610            async move {
8611                let resp = app
8612                    .oneshot(
8613                        Request::builder()
8614                            .uri(uri)
8615                            .header(header::COOKIE, cookie)
8616                            .body(Body::empty())
8617                            .unwrap(),
8618                    )
8619                    .await
8620                    .unwrap();
8621                assert_eq!(resp.status(), StatusCode::OK);
8622                let bytes = axum::body::to_bytes(resp.into_body(), 8 * 1024 * 1024)
8623                    .await
8624                    .unwrap();
8625                String::from_utf8(bytes.to_vec()).unwrap()
8626            }
8627        };
8628
8629        let page1 = get("/").await;
8630        // One `<li class="entry…>` per rendered row. Counting "/entries/" would
8631        // over-count: each row carries several (the link plus the read/star
8632        // forms).
8633        let rows1 = page1.matches("<li class=\"entry").count();
8634        assert!(
8635            rows1 <= ENTRIES_PER_PAGE as usize,
8636            "page 1 rendered {rows1} entry links; the list is unbounded"
8637        );
8638        assert!(
8639            rows1 > 0,
8640            "page 1 rendered nothing at all: the page bound swallowed the list"
8641        );
8642        // The count is the TRUE total, not the page size — otherwise paging
8643        // would quietly relabel a 250-entry backlog as a 100-entry one.
8644        assert!(
8645            page1.contains("250 entries"),
8646            "heading must report the full total, not the page"
8647        );
8648        assert!(
8649            page1.contains("page=2"),
8650            "no way to reach the rest of the list: {}",
8651            &page1[..page1.len().min(400)]
8652        );
8653        // The body never belongs in a list response.
8654        assert!(
8655            !page1.contains(&"x".repeat(4_000)),
8656            "the list response carried an article body"
8657        );
8658
8659        let page2 = get("/?page=2").await;
8660        assert!(
8661            page2.matches("<li class=\"entry").count() > 0,
8662            "page 2 rendered no rows at all"
8663        );
8664        assert!(
8665            page2.contains("page=1") || page2.contains("Newer"),
8666            "page 2 offers no way back"
8667        );
8668        // Disjoint: an article on page 1 must not reappear on page 2.
8669        let first_title = (0..total)
8670            .map(|i| format!("Article {i:04}"))
8671            .find(|t| page1.contains(t))
8672            .expect("page 1 shows at least one titled article");
8673        assert!(
8674            !page2.contains(&first_title),
8675            "{first_title} appears on both pages"
8676        );
8677
8678        // A page past the end must not be a dead end. The empty state renders
8679        // instead of the pager, so an out-of-range page would leave a reader
8680        // with no link back — reachable by typing a number, and reachable
8681        // WITHOUT typing anything by paging to the end and then marking entries
8682        // read, which shrinks the list under the URL already in the address bar.
8683        let past_end = get("/?page=999").await;
8684        assert!(
8685            past_end.matches("<li class=\"entry").count() > 0,
8686            "an out-of-range page rendered nothing and offered no way back"
8687        );
8688        assert!(
8689            past_end.contains("page=2"),
8690            "the clamped page offers no pager"
8691        );
8692    }
8693
8694    /// Reader-view mark-read (a request tagged `X-FR-Reader: 1`) must return the
8695    /// out-of-band action-bar fragment with FRESHLY re-read state so a second
8696    /// keypress reverses the toggle: `hx-swap-oob="outerHTML"` is present, and
8697    /// the hidden `read` input + `aria-pressed` reflect the NEW state. The list
8698    /// view (no reader header) instead swaps the row. This guards the reader OOB
8699    /// toggle wiring, which had no test.
8700    #[tokio::test]
8701    async fn reader_mark_read_returns_oob_actionbar_with_flipped_state() {
8702        let did = "did:plc:reader";
8703        let state = test_state(&[]).await;
8704        store::grant_access(&state.db, did, None, "test", None)
8705            .await
8706            .unwrap();
8707        let feed = store::upsert_feed(
8708            &state.db,
8709            &store::NewFeed {
8710                url: "https://reader.example/feed.xml".to_string(),
8711                title: Some("Reader".to_string()),
8712                ..Default::default()
8713            },
8714        )
8715        .await
8716        .unwrap();
8717        store::insert_entries(
8718            &state.db,
8719            feed,
8720            &[store::NewEntry {
8721                guid: "r-1".to_string(),
8722                url: Some("https://reader.example/1".to_string()),
8723                title: Some("Article".to_string()),
8724                published: Some("2026-07-11T00:00:00Z".to_string()),
8725                content_html: Some("<p>body</p>".to_string()),
8726                ..Default::default()
8727            }],
8728            0,
8729        )
8730        .await
8731        .unwrap();
8732        store::replace_sub_refs(&state.db, did, &[feed])
8733            .await
8734            .unwrap();
8735        let entry_id = store::entries_for_feed(&state.db, did, feed).await.unwrap()[0].id;
8736
8737        let cookie = session_cookie(&state, did, None);
8738        let app = router(state.clone());
8739
8740        // Reader-tagged mark-read → OOB action-bar fragment, entry now READ.
8741        let resp = app
8742            .clone()
8743            .oneshot(
8744                Request::builder()
8745                    .method("POST")
8746                    .uri(format!("/entries/{entry_id}/read"))
8747                    .header(header::COOKIE, cookie.clone())
8748                    .header("HX-Request", "true")
8749                    .header("X-FR-Reader", "1")
8750                    .header("content-type", "application/x-www-form-urlencoded")
8751                    .body(Body::from("read=true"))
8752                    .unwrap(),
8753            )
8754            .await
8755            .unwrap();
8756        assert_eq!(resp.status(), StatusCode::OK);
8757        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
8758            .await
8759            .unwrap();
8760        let html = String::from_utf8(bytes.to_vec()).unwrap();
8761        assert!(
8762            html.contains("hx-swap-oob=\"outerHTML\""),
8763            "reader response must be an OOB swap: {html}"
8764        );
8765        assert!(
8766            html.contains(r#"id="entry-actionbar""#),
8767            "reader response must be the action-bar fragment: {html}"
8768        );
8769        // Now READ: the read button reflects it (aria-pressed=true) and the
8770        // hidden value flips to `false` so the next tap marks it UNREAD.
8771        assert!(
8772            html.contains(r#"aria-pressed="true""#),
8773            "read button must show pressed after marking read: {html}"
8774        );
8775        assert!(
8776            html.contains(r#"name="read" value="false""#),
8777            "hidden read value must flip to false so a second tap reverses: {html}"
8778        );
8779
8780        // A second reader mark-read (submitting the flipped `read=false`) marks
8781        // it UNREAD again — the toggle reverses.
8782        let resp2 = app
8783            .oneshot(
8784                Request::builder()
8785                    .method("POST")
8786                    .uri(format!("/entries/{entry_id}/read"))
8787                    .header(header::COOKIE, cookie)
8788                    .header("HX-Request", "true")
8789                    .header("X-FR-Reader", "1")
8790                    .header("content-type", "application/x-www-form-urlencoded")
8791                    .body(Body::from("read=false"))
8792                    .unwrap(),
8793            )
8794            .await
8795            .unwrap();
8796        assert_eq!(resp2.status(), StatusCode::OK);
8797        let bytes2 = axum::body::to_bytes(resp2.into_body(), 64 * 1024)
8798            .await
8799            .unwrap();
8800        let html2 = String::from_utf8(bytes2.to_vec()).unwrap();
8801        assert!(
8802            html2.contains(r#"aria-pressed="false""#),
8803            "read button must show un-pressed after reversing: {html2}"
8804        );
8805        assert!(
8806            html2.contains(r#"name="read" value="true""#),
8807            "hidden read value must flip back to true: {html2}"
8808        );
8809    }
8810
8811    /// The LIST view (no `X-FR-Reader` header) swaps the row, not the OOB
8812    /// action-bar — the counterpart to the reader-OOB test above.
8813    #[tokio::test]
8814    async fn list_mark_read_returns_row_not_oob_actionbar() {
8815        let did = "did:plc:listv";
8816        let state = test_state(&[]).await;
8817        store::grant_access(&state.db, did, None, "test", None)
8818            .await
8819            .unwrap();
8820        let feed = store::upsert_feed(
8821            &state.db,
8822            &store::NewFeed {
8823                url: "https://list.example/feed.xml".to_string(),
8824                title: Some("List".to_string()),
8825                ..Default::default()
8826            },
8827        )
8828        .await
8829        .unwrap();
8830        store::insert_entries(
8831            &state.db,
8832            feed,
8833            &[store::NewEntry {
8834                guid: "l-1".to_string(),
8835                url: Some("https://list.example/1".to_string()),
8836                title: Some("Article".to_string()),
8837                published: Some("2026-07-11T00:00:00Z".to_string()),
8838                ..Default::default()
8839            }],
8840            0,
8841        )
8842        .await
8843        .unwrap();
8844        store::replace_sub_refs(&state.db, did, &[feed])
8845            .await
8846            .unwrap();
8847        let entry_id = store::entries_for_feed(&state.db, did, feed).await.unwrap()[0].id;
8848
8849        let cookie = session_cookie(&state, did, None);
8850        let app = router(state.clone());
8851
8852        let resp = app
8853            .oneshot(
8854                Request::builder()
8855                    .method("POST")
8856                    .uri(format!("/entries/{entry_id}/read"))
8857                    .header(header::COOKIE, cookie)
8858                    .header("HX-Request", "true")
8859                    .header("content-type", "application/x-www-form-urlencoded")
8860                    .body(Body::from("read=true"))
8861                    .unwrap(),
8862            )
8863            .await
8864            .unwrap();
8865        assert_eq!(resp.status(), StatusCode::OK);
8866        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
8867            .await
8868            .unwrap();
8869        let html = String::from_utf8(bytes.to_vec()).unwrap();
8870        assert!(
8871            !html.contains("hx-swap-oob"),
8872            "list-view response must NOT be an OOB swap: {html}"
8873        );
8874        // **And it must actually BE the row.** The assertion above is satisfied
8875        // by an empty body, or by any response that simply omits the attribute —
8876        // so on its own it pins half a property and the name promises the other
8877        // half.
8878        assert!(
8879            html.contains(&format!("/entries/{entry_id}")),
8880            "the response is not the row for this entry: {html}",
8881        );
8882        assert!(
8883            html.contains("Article"),
8884            "the row rendered without its title: {html}",
8885        );
8886        // **The row comes back carrying read state. That is all this proves.**
8887        //
8888        // It does NOT prove the state was persisted: the handler renders
8889        // `Some(read)` from the form value, so making `mark_read` roll back
8890        // instead of commit fails 11 store tests and leaves this one green.
8891        //
8892        // It does not prove the OVERRIDE either, which an earlier version of
8893        // this comment claimed. Verified: changing the call site to
8894        // `build_entry_row(pool, &did, id, None)` — deleting the override
8895        // wholesale — keeps the whole suite green, because `mark_read` has
8896        // already persisted the same value two lines earlier, so reading it back
8897        // from the database produces an identical row.
8898        //
8899        // Distinguishing the two needs a case where the override and the stored
8900        // state DISAGREE, which this handler never produces: it writes the value
8901        // it then renders. Left as a known gap rather than described as covered.
8902        assert!(
8903            html.contains("is-read"),
8904            "the row came back without the read state it was just given: {html}",
8905        );
8906    }
8907
8908    // -----------------------------------------------------------------------
8909    // Rename parity (POST /subscriptions/{rkey}/rename)
8910    // -----------------------------------------------------------------------
8911
8912    /// **Autodiscovery cannot smuggle a non-http(s) URL into storage.**
8913    ///
8914    /// The add path gates the URL the user *typed*; the URL it *stores* is
8915    /// whatever `resolve_feed_url` returns, which for an HTML page is a
8916    /// publisher-controlled `<link rel="alternate">` href. Two layers stop
8917    /// that: `discover_feed` yields only http(s), and the add path re-checks
8918    /// storability on the resolved URL. This test pins the DISJUNCTION —
8919    /// each layer alone holds it, both removed fails it — driven through the
8920    /// real route against a real local server.
8921    ///
8922    /// **Why the fixture is `ftp://`, not `at://`.** This began as the
8923    /// at-URI bypass test from #164, and it was vacuous twice over. Handle
8924    /// form: once storage became DID-only the privacy classifier refused it
8925    /// at its own gate. DID form: `Url::parse` cannot read it (invalid port
8926    /// — the colons in the DID), so `discover_feed` drops it before either
8927    /// layer exists. An at:// link cannot come out of autodiscovery under
8928    /// ANY mutation of the layers, so no test through this route can pin
8929    /// them with one. `ftp://` reaches both. The at:// case is guaranteed by
8930    /// structure and pinned where it lives: `discover_skips_a_non_http_
8931    /// alternate` and the storability tests in `feed.rs`.
8932    #[tokio::test]
8933    async fn autodiscovery_cannot_smuggle_a_non_http_url_into_storage() {
8934        let did = "did:plc:autodiscovered";
8935        // Access granted, both caps disabled — the only gates left are the
8936        // two under test.
8937        let state = test_state_with_caps(did, 0, 0).await;
8938
8939        let page = r#"<!doctype html><html><head><title>Blog</title>
8940            <link rel="alternate" type="application/rss+xml" href="ftp://files.example/feed.xml">
8941            </head><body>hi</body></html>"#;
8942        let base = crate::net::tests::serve_body(page.as_bytes().to_vec()).await;
8943        let port: u16 = base
8944            .trim_end_matches('/')
8945            .rsplit(':')
8946            .next()
8947            .unwrap()
8948            .parse()
8949            .unwrap();
8950        crate::net::test_host_override(
8951            "autodiscover-ftp.test",
8952            std::net::SocketAddr::from(([127, 0, 0, 1], port)),
8953        );
8954
8955        let cookie = session_cookie(&state, did, None);
8956        let resp = router(state.clone())
8957            .oneshot(
8958                Request::builder()
8959                    .method("POST")
8960                    .uri("/subscriptions")
8961                    .header(header::COOKIE, cookie)
8962                    .header("content-type", "application/x-www-form-urlencoded")
8963                    .body(Body::from(format!(
8964                        "url=http://autodiscover-ftp.test:{port}/"
8965                    )))
8966                    .unwrap(),
8967            )
8968            .await
8969            .unwrap();
8970        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8971        let loc = resp
8972            .headers()
8973            .get(header::LOCATION)
8974            .unwrap()
8975            .to_str()
8976            .unwrap();
8977        assert_ne!(loc, "/login", "the test never reached the add path");
8978        assert_ne!(loc, "/", "the subscribe succeeded");
8979
8980        assert_eq!(
8981            store::count_feeds(&state.db).await.unwrap(),
8982            0,
8983            "a non-http(s) URL from autodiscovery was stored"
8984        );
8985        assert_eq!(
8986            store::count_subscriptions_for_did(&state.db, did)
8987                .await
8988                .unwrap(),
8989            0
8990        );
8991    }
8992
8993    /// A rename that points at a BRAND-NEW feed URL while the shared cache is at
8994    /// its global ceiling must be refused (capacity flash) and must NOT insert a
8995    /// new `feeds` row — parity with add_subscription's global-cap guard, so a
8996    /// rename loop can't inflate the shared cache past the cap.
8997    #[tokio::test]
8998    async fn rename_to_new_url_refused_at_global_feeds_cap() {
8999        let did = "did:plc:renamer4";
9000        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
9001        // Global cap 1; pre-fill it with one feed so headroom is 0.
9002        let state = test_state_with_sidecar_and(&[did], &sidecar, false, 1).await;
9003        store::upsert_feed(
9004            &state.db,
9005            &store::NewFeed {
9006                url: "https://existing.example/feed.xml".to_string(),
9007                ..Default::default()
9008            },
9009        )
9010        .await
9011        .unwrap();
9012        let before = store::count_feeds(&state.db).await.unwrap();
9013        assert_eq!(before, 1);
9014
9015        let cookie = session_cookie(&state, did, None);
9016        let resp = router(state.clone())
9017            .oneshot(
9018                Request::builder()
9019                    .method("POST")
9020                    .uri("/subscriptions/rk-keep/rename")
9021                    .header(header::COOKIE, cookie)
9022                    .header("content-type", "application/x-www-form-urlencoded")
9023                    // A URL not in the cache → would be a NEW feeds row.
9024                    .body(Body::from(
9025                        "url=https://brand-new.example/feed.xml&title=Renamed",
9026                    ))
9027                    .unwrap(),
9028            )
9029            .await
9030            .unwrap();
9031        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9032        let loc = resp
9033            .headers()
9034            .get(header::LOCATION)
9035            .unwrap()
9036            .to_str()
9037            .unwrap();
9038        assert!(
9039            loc.contains("feed%20capacity"),
9040            "expected the feed-capacity flash, got {loc}"
9041        );
9042        // No new feeds row was inserted, and nothing reached the PDS.
9043        assert_eq!(store::count_feeds(&state.db).await.unwrap(), before);
9044        assert!(
9045            puts.lock().unwrap().is_empty(),
9046            "a refused repoint reached the PDS"
9047        );
9048    }
9049
9050    /// A repoint to an EXISTING URL adds no row, so it is allowed even at the
9051    /// global cap (only new URLs are gated) — the other half of the guard.
9052    ///
9053    /// On the sidecar fake, so "allowed" means the put actually happened: the
9054    /// earlier harness had no sidecar, and this passed on a "could not reach
9055    /// your PDS" flash that merely was not the capacity one.
9056    #[tokio::test]
9057    async fn rename_to_existing_url_allowed_at_global_feeds_cap() {
9058        let did = "did:plc:renamer4";
9059        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
9060        let state = test_state_with_sidecar_and(&[did], &sidecar, false, 1).await;
9061        store::upsert_feed(
9062            &state.db,
9063            &store::NewFeed {
9064                url: "https://existing.example/feed.xml".to_string(),
9065                ..Default::default()
9066            },
9067        )
9068        .await
9069        .unwrap();
9070        let before = store::count_feeds(&state.db).await.unwrap();
9071
9072        let cookie = session_cookie(&state, did, None);
9073        let resp = router(state.clone())
9074            .oneshot(
9075                Request::builder()
9076                    .method("POST")
9077                    .uri("/subscriptions/rk-keep/rename")
9078                    .header(header::COOKIE, cookie)
9079                    .header("content-type", "application/x-www-form-urlencoded")
9080                    .body(Body::from(
9081                        "url=https://existing.example/feed.xml&title=Retitled",
9082                    ))
9083                    .unwrap(),
9084            )
9085            .await
9086            .unwrap();
9087        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9088        let loc = resp
9089            .headers()
9090            .get(header::LOCATION)
9091            .unwrap()
9092            .to_str()
9093            .unwrap();
9094        assert_eq!(loc, "/", "the repoint to a cached URL was refused: {loc}");
9095        assert_eq!(
9096            puts.lock().unwrap().len(),
9097            1,
9098            "the repoint did not reach the PDS"
9099        );
9100        assert_eq!(store::count_feeds(&state.db).await.unwrap(), before);
9101    }
9102
9103    /// A rename with a blank URL writes nothing anywhere.
9104    #[tokio::test]
9105    async fn rename_with_blank_url_writes_nothing() {
9106        let did = "did:plc:renamer3";
9107        let state = test_state_with_caps(did, 0, 0).await;
9108        let before = store::count_feeds(&state.db).await.unwrap();
9109        assert_eq!(before, 0);
9110
9111        let cookie = session_cookie(&state, did, None);
9112        let app = router(state.clone());
9113        let resp = app
9114            .oneshot(
9115                Request::builder()
9116                    .method("POST")
9117                    .uri("/subscriptions/rkey123/rename")
9118                    .header(header::COOKIE, cookie)
9119                    .header("content-type", "application/x-www-form-urlencoded")
9120                    // Whitespace-only URL trims to empty.
9121                    .body(Body::from("url=%20%20&title=Nope"))
9122                    .unwrap(),
9123            )
9124            .await
9125            .unwrap();
9126        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9127        assert_eq!(
9128            resp.headers()
9129                .get(header::LOCATION)
9130                .unwrap()
9131                .to_str()
9132                .unwrap(),
9133            "/",
9134        );
9135        // Nothing was cached.
9136        assert_eq!(
9137            store::count_feeds(&state.db).await.unwrap(),
9138            0,
9139            "blank-URL rename wrote a junk feeds row"
9140        );
9141    }
9142
9143    /// A sidecar mock that serves ONE existing subscription record and captures
9144    /// every `put` body a rename produces.
9145    ///
9146    /// **Reads to `content-length` rather than taking one `read`.** A single
9147    /// read gets whatever one segment carried; if the head and body land
9148    /// separately the capture holds no record and every field assertion below
9149    /// passes for the wrong reason. Each captured body must also mention the
9150    /// collection, so an empty capture fails loudly instead of quietly.
9151    async fn spawn_rename_sidecar(
9152        existing: serde_json::Value,
9153    ) -> (String, std::sync::Arc<std::sync::Mutex<Vec<String>>>) {
9154        use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
9155        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9156        let addr = listener.local_addr().unwrap();
9157        let puts = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
9158        let sink = puts.clone();
9159        tokio::spawn(async move {
9160            loop {
9161                let Ok((mut sock, _)) = listener.accept().await else {
9162                    break;
9163                };
9164                let mut raw: Vec<u8> = Vec::new();
9165                let mut chunk = [0u8; 4096];
9166                let body_text = loop {
9167                    let Ok(n) = sock.read(&mut chunk).await else {
9168                        break String::new();
9169                    };
9170                    if n == 0 {
9171                        break String::from_utf8_lossy(&raw).to_string();
9172                    }
9173                    raw.extend_from_slice(&chunk[..n]);
9174                    let Some(split) = raw.windows(4).position(|w| w == b"\r\n\r\n") else {
9175                        continue;
9176                    };
9177                    let (head, body) = raw.split_at(split + 4);
9178                    let want = String::from_utf8_lossy(head).lines().find_map(|l| {
9179                        let (k, v) = l.split_once(':')?;
9180                        k.eq_ignore_ascii_case("content-length")
9181                            .then(|| v.trim().parse::<usize>().ok())?
9182                    });
9183                    if want.is_none_or(|want| body.len() >= want) {
9184                        break String::from_utf8_lossy(body).to_string();
9185                    }
9186                };
9187
9188                // `"action":"put"` is the rename write; anything else is the read.
9189                let is_put = body_text.contains("\"action\":\"put\"");
9190                let data = if is_put {
9191                    sink.lock().unwrap().push(body_text.clone());
9192                    serde_json::json!({
9193                        "uri": "at://did:plc:x/community.lexicon.rss.subscription/rk-keep",
9194                        "cid": "bafyreiafter"
9195                    })
9196                } else {
9197                    serde_json::json!({ "records": [existing.clone()] })
9198                };
9199                let body = serde_json::json!({ "ok": true, "data": data }).to_string();
9200                let resp = format!(
9201                    "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
9202                    body.len(),
9203                    body
9204                );
9205                let _ = sock.write_all(resp.as_bytes()).await;
9206                let _ = sock.flush().await;
9207            }
9208        });
9209        (format!("http://{addr}"), puts)
9210    }
9211
9212    /// The existing record a rename must not destroy.
9213    fn seeded_subscription() -> serde_json::Value {
9214        serde_json::json!({
9215            "uri": "at://did:plc:renamer4/community.lexicon.rss.subscription/rk-keep",
9216            "cid": "bafyreibefore",
9217            "value": {
9218                "$type": "community.lexicon.rss.subscription",
9219                "url": "https://example.com/feed.xml",
9220                "title": "Old title",
9221                "siteUrl": "https://example.com/blog",
9222                "fetchHint": "hourly",
9223                "private": false,
9224                "createdAt": "2024-03-01T00:00:00.000Z"
9225            }
9226        })
9227    }
9228
9229    /// An existing standard.site subscription, as the 19 in production are:
9230    /// written before this reader refused the scheme, still in the repo.
9231    fn seeded_at_uri_subscription() -> serde_json::Value {
9232        seeded_subscription_with_url(AT_URI_SUB)
9233    }
9234    /// An existing subscription record at `rk-keep` with the given URL.
9235    fn seeded_subscription_with_url(url: &str) -> serde_json::Value {
9236        serde_json::json!({
9237            "uri": "at://did:plc:renamer5/community.lexicon.rss.subscription/rk-keep",
9238            "cid": "bafyreibefore",
9239            "value": {
9240                "$type": "community.lexicon.rss.subscription",
9241                "url": url,
9242                "title": "Old title",
9243                "private": false,
9244                "createdAt": "2024-03-01T00:00:00.000Z"
9245            }
9246        })
9247    }
9248    const AT_URI_SUB: &str =
9249        "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lab2c4d5e6f7g8h";
9250    const AT_URI_SUB_ENC: &str =
9251        "at%3A%2F%2Fdid%3Aplc%3Aohutz6x5acjmpuulp3x7wxxc%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h";
9252
9253    /// **Retitling an existing `at://` subscription must work with the flag off.**
9254    ///
9255    /// The storability guard was placed before the repo lookup, so it refused
9256    /// any rename whose URL is an at-URI — including a pure title or folder
9257    /// change on a record that already exists. On main that rename succeeded;
9258    /// the 19 production records would have become un-editable. The flag gates
9259    /// what may be STORED in the cache, not whether a reader may edit their own
9260    /// record: the PDS write goes through, the cache row is simply not created.
9261    #[tokio::test]
9262    async fn retitling_an_existing_at_uri_subscription_survives_the_flag_being_off() {
9263        let did = "did:plc:renamer5";
9264        let (sidecar, puts) = spawn_rename_sidecar(seeded_at_uri_subscription()).await;
9265        let state = test_state_with_sidecar(&[did], &sidecar).await;
9266        assert!(
9267            !state.config.standard_site,
9268            "the flag must be off for this test"
9269        );
9270        let cookie = session_cookie(&state, did, None);
9271        let resp = router(state.clone())
9272            .oneshot(
9273                Request::builder()
9274                    .method("POST")
9275                    .uri("/subscriptions/rk-keep/rename")
9276                    .header(header::COOKIE, cookie)
9277                    .header("content-type", "application/x-www-form-urlencoded")
9278                    .body(Body::from(format!("url={AT_URI_SUB_ENC}&title=New+title")))
9279                    .unwrap(),
9280            )
9281            .await
9282            .unwrap();
9283        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9284        let loc = resp
9285            .headers()
9286            .get(header::LOCATION)
9287            .unwrap()
9288            .to_str()
9289            .unwrap();
9290        assert_eq!(loc, "/", "the retitle was refused: {loc}");
9291
9292        let bodies = puts.lock().unwrap().clone();
9293        assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
9294        let sent: serde_json::Value = serde_json::from_str(&bodies[0]).expect("put body is JSON");
9295        assert_eq!(
9296            sent["record"]["title"], "New title",
9297            "the rename did not apply"
9298        );
9299        assert_eq!(
9300            sent["record"]["url"], AT_URI_SUB,
9301            "the rename changed the URL"
9302        );
9303
9304        // The flag still means what it says for the CACHE: no at:// row.
9305        let cached: i64 = store::count_unpollable_feeds(&state.db).await.unwrap();
9306        assert_eq!(cached, 0, "a retitle stored an at:// row with the flag off");
9307    }
9308
9309    /// **Repointing a subscription AT an `at://` URI is still refused with the
9310    /// flag off** — the half of the guard that has to survive the fix above.
9311    /// Nothing reaches the PDS and nothing reaches the cache.
9312    #[tokio::test]
9313    async fn repointing_a_subscription_at_an_at_uri_is_refused_with_the_flag_off() {
9314        let did = "did:plc:renamer4";
9315        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
9316        let state = test_state_with_sidecar(&[did], &sidecar).await;
9317        let cookie = session_cookie(&state, did, None);
9318        let resp = router(state.clone())
9319            .oneshot(
9320                Request::builder()
9321                    .method("POST")
9322                    .uri("/subscriptions/rk-keep/rename")
9323                    .header(header::COOKIE, cookie)
9324                    .header("content-type", "application/x-www-form-urlencoded")
9325                    .body(Body::from(format!("url={AT_URI_SUB_ENC}&title=Moved")))
9326                    .unwrap(),
9327            )
9328            .await
9329            .unwrap();
9330        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9331        let loc = resp
9332            .headers()
9333            .get(header::LOCATION)
9334            .unwrap()
9335            .to_str()
9336            .unwrap();
9337        assert!(loc.contains("flash="), "the repoint was not refused: {loc}");
9338        assert!(
9339            !loc.contains("Private"),
9340            "a storability refusal was reported as a privacy one: {loc}"
9341        );
9342        assert!(
9343            puts.lock().unwrap().is_empty(),
9344            "the repoint reached the PDS"
9345        );
9346        let cached: i64 = store::count_unpollable_feeds(&state.db).await.unwrap();
9347        assert_eq!(cached, 0);
9348    }
9349
9350    /// Posts a retitle of `rk-keep` with its URL unchanged; returns the
9351    /// redirect location.
9352    async fn retitle_unchanged(state: &AppState, did: &str, url_enc: &str) -> String {
9353        let cookie = session_cookie(state, did, None);
9354        let resp = router(state.clone())
9355            .oneshot(
9356                Request::builder()
9357                    .method("POST")
9358                    .uri("/subscriptions/rk-keep/rename")
9359                    .header(header::COOKIE, cookie)
9360                    .header("content-type", "application/x-www-form-urlencoded")
9361                    .body(Body::from(format!("url={url_enc}&title=New+title")))
9362                    .unwrap(),
9363            )
9364            .await
9365            .unwrap();
9366        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9367        resp.headers()
9368            .get(header::LOCATION)
9369            .unwrap()
9370            .to_str()
9371            .unwrap()
9372            .to_string()
9373    }
9374
9375    /// **The privacy gate has the same ordering bug the storable gate had.**
9376    ///
9377    /// Another client can write a subscription whose URL is an at-URI that is
9378    /// not a well-formed publication URI at all — a feed generator, say. On
9379    /// main a retitle of it succeeded (the DID form fails `Url::parse`, which
9380    /// the classifier reads as `Public`). The narrowed at:// arm now fails
9381    /// closed as `Private` for it, and the gate ran before `url_changed` was
9382    /// known — so the record became un-editable, with a flash claiming it "was
9383    /// not saved or sent anywhere". Both gates now apply to a repoint only.
9384    #[tokio::test]
9385    async fn retitling_an_existing_at_uri_record_that_is_not_a_publication_survives() {
9386        let did = "did:plc:renamer5";
9387        let other = "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/app.bsky.feed.generator/whats-hot";
9388        let other_enc =
9389            "at%3A%2F%2Fdid%3Aplc%3Aohutz6x5acjmpuulp3x7wxxc%2Fapp.bsky.feed.generator%2Fwhats-hot";
9390        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription_with_url(other)).await;
9391        let state = test_state_with_sidecar(&[did], &sidecar).await;
9392        let loc = retitle_unchanged(&state, did, other_enc).await;
9393        assert_eq!(loc, "/", "the retitle was refused: {loc}");
9394        let bodies = puts.lock().unwrap().clone();
9395        assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
9396        let sent: serde_json::Value = serde_json::from_str(&bodies[0]).unwrap();
9397        assert_eq!(sent["record"]["title"], "New title");
9398        assert_eq!(sent["record"]["url"], other);
9399    }
9400
9401    /// **A repoint to a secret-bearing URL is still refused** — the half of
9402    /// the privacy gate that has to survive moving it behind `url_changed`.
9403    /// Found by mutation: with the gate deleted outright, nothing failed.
9404    #[tokio::test]
9405    async fn repointing_a_subscription_at_a_private_feed_is_refused() {
9406        let did = "did:plc:renamer4";
9407        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
9408        let state = test_state_with_sidecar(&[did], &sidecar).await;
9409        let cookie = session_cookie(&state, did, None);
9410        let resp = router(state.clone())
9411            .oneshot(
9412                Request::builder()
9413                    .method("POST")
9414                    .uri("/subscriptions/rk-keep/rename")
9415                    .header(header::COOKIE, cookie)
9416                    .header("content-type", "application/x-www-form-urlencoded")
9417                    .body(Body::from(
9418                        "url=https%3A%2F%2Fpaid.example%2Ffeed.xml%3Ftoken%3DZm9vYmFyc2VjcmV0dG9rZW4&title=Moved",
9419                    ))
9420                    .unwrap(),
9421            )
9422            .await
9423            .unwrap();
9424        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9425        let loc = resp
9426            .headers()
9427            .get(header::LOCATION)
9428            .unwrap()
9429            .to_str()
9430            .unwrap();
9431        assert!(
9432            loc.contains("Private"),
9433            "the private repoint was not refused: {loc}"
9434        );
9435        assert!(
9436            puts.lock().unwrap().is_empty(),
9437            "a secret-bearing URL reached the PDS"
9438        );
9439        // The repo's fixture token: opaque enough for the classifier, not a real
9440        // key shape (a Stripe-shaped fixture tripped the secret scanner — rightly).
9441        let leaked = "https://paid.example/feed.xml?token=Zm9vYmFyc2VjcmV0dG9rZW4";
9442        assert!(store::get_feed_by_url(&state.db, leaked)
9443            .await
9444            .unwrap()
9445            .is_none());
9446    }
9447
9448    /// **A retitle of a never-cached at:// subscription is not "at feed
9449    /// capacity".** The global-ceiling check keyed on "URL not in the cache",
9450    /// and an at:// record is never cached with the flag off — so at capacity,
9451    /// a pure retitle was refused for a row the handler would not insert. The
9452    /// check now runs once `url_changed` is known and only for a repoint.
9453    #[tokio::test]
9454    async fn retitling_an_uncached_at_uri_subscription_is_not_refused_at_feed_capacity() {
9455        let did = "did:plc:renamer5";
9456        let (sidecar, puts) = spawn_rename_sidecar(seeded_at_uri_subscription()).await;
9457        // Ceiling 1, and one real feed already fills it.
9458        let state = test_state_with_sidecar_and(&[did], &sidecar, false, 1).await;
9459        store::upsert_feed(
9460            &state.db,
9461            &store::NewFeed {
9462                url: "https://filler.example/feed.xml".to_string(),
9463                ..Default::default()
9464            },
9465        )
9466        .await
9467        .unwrap();
9468        let loc = retitle_unchanged(&state, did, AT_URI_SUB_ENC).await;
9469        assert_eq!(loc, "/", "the retitle was refused: {loc}");
9470        assert_eq!(
9471            puts.lock().unwrap().len(),
9472            1,
9473            "the retitle did not reach the PDS"
9474        );
9475        assert_eq!(
9476            store::count_feeds(&state.db).await.unwrap(),
9477            1,
9478            "a row was inserted"
9479        );
9480    }
9481
9482    /// **With the flag ON, a well-formed at:// paste is still refused as
9483    /// unsupported** — not "Couldn't find a feed" plus a `warn!`. Nothing can
9484    /// fetch `at://` until the reader is wired, whatever the flag says, and the
9485    /// docs promise this answer "with the flag on or off". This is also the
9486    /// suite's first state with the flag on: every other site passes the flag
9487    /// through with `false`, where a literal `false` would be indistinguishable.
9488    #[tokio::test]
9489    async fn a_well_formed_at_uri_paste_is_refused_as_unsupported_with_the_flag_on() {
9490        let did = "did:plc:renamer5";
9491        let (sidecar, _puts) = spawn_rename_sidecar(seeded_subscription()).await;
9492        let state = test_state_with_sidecar_and(&[did], &sidecar, true, 0).await;
9493        let cookie = session_cookie(&state, did, None);
9494        let resp = router(state.clone())
9495            .oneshot(
9496                Request::builder()
9497                    .method("POST")
9498                    .uri("/subscriptions")
9499                    .header(header::COOKIE, cookie)
9500                    .header("content-type", "application/x-www-form-urlencoded")
9501                    .body(Body::from(format!("url={AT_URI_SUB_ENC}")))
9502                    .unwrap(),
9503            )
9504            .await
9505            .unwrap();
9506        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9507        let loc = resp
9508            .headers()
9509            .get(header::LOCATION)
9510            .unwrap()
9511            .to_str()
9512            .unwrap();
9513        assert!(
9514            loc.contains("kind%20of%20feed"),
9515            "expected the unsupported flash: {loc}"
9516        );
9517        assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
9518    }
9519
9520    /// **With the flag ON, an OPML at:// entry is stored.** The one storage
9521    /// path that is meant to work today, asserted with the flag actually on.
9522    #[tokio::test]
9523    async fn opml_import_stores_an_at_uri_entry_with_the_flag_on() {
9524        let did = "did:plc:renamer5";
9525        let (sidecar, _puts) = spawn_rename_sidecar(seeded_subscription()).await;
9526        let state = test_state_with_sidecar_and(&[did], &sidecar, true, 0).await;
9527        let opml = format!(
9528            "<?xml version=\"1.0\"?>\n<opml version=\"2.0\"><head><title>t</title></head><body>\n\
9529             <outline type=\"rss\" text=\"Real\" xmlUrl=\"https://real.example/feed.xml\"/>\n\
9530             <outline type=\"rss\" text=\"Pub\" xmlUrl=\"{AT_URI_SUB}\"/>\n\
9531             </body></opml>"
9532        );
9533        let (ct, body) = opml_multipart(opml.as_bytes());
9534        let cookie = session_cookie(&state, did, None);
9535        let resp = router(state.clone())
9536            .oneshot(
9537                Request::builder()
9538                    .method("POST")
9539                    .uri("/opml")
9540                    .header(header::COOKIE, cookie)
9541                    .header("content-type", ct)
9542                    .body(Body::from(body))
9543                    .unwrap(),
9544            )
9545            .await
9546            .unwrap();
9547        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9548        let loc = resp
9549            .headers()
9550            .get(header::LOCATION)
9551            .unwrap()
9552            .to_str()
9553            .unwrap();
9554        assert!(
9555            loc.contains("Imported%202%20feeds"),
9556            "unexpected flash: {loc}"
9557        );
9558        assert!(
9559            !loc.contains("skipped"),
9560            "the at:// entry was skipped with the flag on: {loc}"
9561        );
9562        let stored = store::get_feed_by_url(&state.db, AT_URI_SUB).await.unwrap();
9563        assert!(
9564            stored.is_some(),
9565            "the at:// entry was not stored with the flag on"
9566        );
9567    }
9568
9569    /// **A retitle must not cache a secret-bearing URL.** Moving the privacy
9570    /// gate behind `url_changed` was right for the PDS write — the record is
9571    /// the reader's — but the cache write was gated only on `storable`, which
9572    /// any http(s) URL is. So a retitle of a record another client wrote with
9573    /// a tokened feed URL inserted that URL into the shared `feeds` table,
9574    /// where the poller would fail it every cycle and print it on the admin
9575    /// page. main refused the whole rename; this keeps the record editable and
9576    /// the cache clean, as `resolve_subscriptions` already does for the same
9577    /// record.
9578    #[tokio::test]
9579    async fn retitling_a_secret_bearing_record_does_not_cache_its_url() {
9580        let did = "did:plc:renamer5";
9581        let tokened = "https://www.patreon.com/rss/author?auth=Zm9vYmFyc2VjcmV0dG9rZW4";
9582        let tokened_enc =
9583            "https%3A%2F%2Fwww.patreon.com%2Frss%2Fauthor%3Fauth%3DZm9vYmFyc2VjcmV0dG9rZW4";
9584        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription_with_url(tokened)).await;
9585        let state = test_state_with_sidecar(&[did], &sidecar).await;
9586        let loc = retitle_unchanged(&state, did, tokened_enc).await;
9587        assert_eq!(loc, "/", "the retitle was refused: {loc}");
9588        assert_eq!(
9589            puts.lock().unwrap().len(),
9590            1,
9591            "the retitle did not reach the PDS"
9592        );
9593        assert!(
9594            store::get_feed_by_url(&state.db, tokened)
9595                .await
9596                .unwrap()
9597                .is_none(),
9598            "a secret-bearing URL was written to the shared cache by a retitle"
9599        );
9600    }
9601
9602    /// **On a repoint, storability is decided before privacy and capacity** —
9603    /// the same ordering the add path got. A malformed at:// target drew the
9604    /// private/paid flash, and at capacity a well-formed one drew "try again
9605    /// later" for a URL that can never be accepted with the flag off.
9606    #[tokio::test]
9607    async fn repointing_at_a_malformed_at_uri_is_refused_as_unsupported() {
9608        let did = "did:plc:renamer4";
9609        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
9610        let state = test_state_with_sidecar(&[did], &sidecar).await;
9611        let cookie = session_cookie(&state, did, None);
9612        let resp = router(state.clone())
9613            .oneshot(
9614                Request::builder()
9615                    .method("POST")
9616                    .uri("/subscriptions/rk-keep/rename")
9617                    .header(header::COOKIE, cookie)
9618                    .header("content-type", "application/x-www-form-urlencoded")
9619                    .body(Body::from(
9620                        "url=at%3A%2F%2Fdid%3Aplc%3ATOOSHORT%2Fsite.standard.publication%2F3lab&title=Moved",
9621                    ))
9622                    .unwrap(),
9623            )
9624            .await
9625            .unwrap();
9626        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9627        let loc = resp
9628            .headers()
9629            .get(header::LOCATION)
9630            .unwrap()
9631            .to_str()
9632            .unwrap();
9633        assert!(
9634            loc.contains("kind%20of%20feed"),
9635            "expected the unsupported flash: {loc}"
9636        );
9637        assert!(
9638            !loc.contains("Private"),
9639            "a typo was reported as a paid feed: {loc}"
9640        );
9641        assert!(puts.lock().unwrap().is_empty());
9642    }
9643
9644    #[tokio::test]
9645    async fn repointing_at_an_at_uri_at_capacity_is_refused_as_unsupported_not_capacity() {
9646        let did = "did:plc:renamer4";
9647        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
9648        let state = test_state_with_sidecar_and(&[did], &sidecar, false, 1).await;
9649        store::upsert_feed(
9650            &state.db,
9651            &store::NewFeed {
9652                url: "https://filler.example/feed.xml".to_string(),
9653                ..Default::default()
9654            },
9655        )
9656        .await
9657        .unwrap();
9658        let cookie = session_cookie(&state, did, None);
9659        let resp = router(state.clone())
9660            .oneshot(
9661                Request::builder()
9662                    .method("POST")
9663                    .uri("/subscriptions/rk-keep/rename")
9664                    .header(header::COOKIE, cookie)
9665                    .header("content-type", "application/x-www-form-urlencoded")
9666                    .body(Body::from(format!("url={AT_URI_SUB_ENC}&title=Moved")))
9667                    .unwrap(),
9668            )
9669            .await
9670            .unwrap();
9671        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9672        let loc = resp
9673            .headers()
9674            .get(header::LOCATION)
9675            .unwrap()
9676            .to_str()
9677            .unwrap();
9678        assert!(
9679            loc.contains("kind%20of%20feed"),
9680            "expected the unsupported flash: {loc}"
9681        );
9682        assert!(
9683            !loc.contains("capacity"),
9684            "an unacceptable URL was reported as a capacity problem: {loc}"
9685        );
9686        assert!(puts.lock().unwrap().is_empty());
9687    }
9688
9689    /// **`url_changed` compares like for like.** The form value is trimmed;
9690    /// the record's URL was compared raw, so a record another client wrote
9691    /// with a trailing space read as a repoint on every retitle and re-armed
9692    /// every gate — including the one that made an at:// record un-editable.
9693    #[tokio::test]
9694    async fn retitling_a_record_whose_url_carries_whitespace_is_not_a_repoint() {
9695        let did = "did:plc:renamer5";
9696        let padded = format!("{AT_URI_SUB} ");
9697        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription_with_url(&padded)).await;
9698        let state = test_state_with_sidecar(&[did], &sidecar).await;
9699        // The manage row posts the record's URL verbatim, padding included.
9700        let loc = retitle_unchanged(&state, did, &format!("{AT_URI_SUB_ENC}%20")).await;
9701        assert_eq!(
9702            loc, "/",
9703            "the retitle was treated as a repoint and refused: {loc}"
9704        );
9705        let bodies = puts.lock().unwrap().clone();
9706        assert_eq!(bodies.len(), 1);
9707        let sent: serde_json::Value = serde_json::from_str(&bodies[0]).unwrap();
9708        assert_eq!(
9709            sent["record"]["url"], AT_URI_SUB,
9710            "the padding was not normalised away"
9711        );
9712    }
9713
9714    /// **A retitle inserts no cache row.** The ceiling is checked on a repoint
9715    /// only, so the trailing upsert must not create a row for an unchanged URL
9716    /// that has none — with the flag on and the cache full, each retitle of a
9717    /// never-cached at:// record was a row past the cap. An existing row still
9718    /// gets its title kept in step.
9719    #[tokio::test]
9720    async fn retitling_an_uncached_record_at_capacity_inserts_no_row() {
9721        let did = "did:plc:renamer5";
9722        let (sidecar, puts) = spawn_rename_sidecar(seeded_at_uri_subscription()).await;
9723        let state = test_state_with_sidecar_and(&[did], &sidecar, true, 1).await;
9724        store::upsert_feed(
9725            &state.db,
9726            &store::NewFeed {
9727                url: "https://filler.example/feed.xml".to_string(),
9728                ..Default::default()
9729            },
9730        )
9731        .await
9732        .unwrap();
9733        let loc = retitle_unchanged(&state, did, AT_URI_SUB_ENC).await;
9734        assert_eq!(loc, "/", "the retitle was refused: {loc}");
9735        assert_eq!(puts.lock().unwrap().len(), 1);
9736        assert_eq!(
9737            store::count_feeds(&state.db).await.unwrap(),
9738            1,
9739            "a retitle inserted a cache row past the ceiling"
9740        );
9741    }
9742
9743    /// **The add path's at:// pre-check is about the MESSAGE, so it is
9744    /// case-insensitive.** `Url::parse` folds the scheme, so `AT://…` skipped
9745    /// the pre-check and the classifier's at:// arm alike, parsed as `at`, and
9746    /// tripped the secret heuristic on the rkey — the private/paid flash the
9747    /// pre-check exists to avoid. Storage stays case-sensitive; this does not
9748    /// touch it.
9749    #[tokio::test]
9750    async fn an_uppercase_at_scheme_paste_is_refused_as_unsupported() {
9751        let did = "did:plc:typoist";
9752        let state = test_state_with_caps(did, 0, 0).await;
9753        let cookie = session_cookie(&state, did, None);
9754        let resp = router(state.clone())
9755            .oneshot(
9756                Request::builder()
9757                    .method("POST")
9758                    .uri("/subscriptions")
9759                    .header(header::COOKIE, cookie)
9760                    .header("content-type", "application/x-www-form-urlencoded")
9761                    .body(Body::from(
9762                        "url=AT%3A%2F%2Falice.example.com%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
9763                    ))
9764                    .unwrap(),
9765            )
9766            .await
9767            .unwrap();
9768        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9769        let loc = resp
9770            .headers()
9771            .get(header::LOCATION)
9772            .unwrap()
9773            .to_str()
9774            .unwrap();
9775        assert!(
9776            loc.contains("kind%20of%20feed"),
9777            "expected the unsupported flash: {loc}"
9778        );
9779        assert!(!loc.contains("Private"), "reported as a paid feed: {loc}");
9780    }
9781
9782    /// **A rename must not destroy the fields the form never carries.**
9783    ///
9784    /// `update_subscription` is a `putRecord` — the WHOLE record is replaced, per
9785    /// its own doc. The handler built a fresh `Subscription::new(url, now())`, so
9786    /// every field absent from `templates/manage_row.html` (which posts only
9787    /// `url`, `title`, `folder`) was written back as its default:
9788    ///
9789    /// | field | before | after |
9790    /// |---|---|---|
9791    /// | `siteUrl` | whatever the feed advertised | gone |
9792    /// | `fetchHint` | as set | gone |
9793    /// | `private` | as set | gone |
9794    /// | `createdAt` | original subscribe time | reset to now |
9795    ///
9796    /// `createdAt` is the worst of the four: it is the sort key for "when did I
9797    /// subscribe", it is unrecoverable once overwritten, and nothing in the UI
9798    /// tells the reader it moved.
9799    ///
9800    /// Asserted on the BYTES THE SIDECAR RECEIVES, not on a `Subscription` built
9801    /// in the test — the record only becomes wrong on the way out, so checking
9802    /// the value we passed in would pass just as happily with the fix removed.
9803    #[tokio::test]
9804    async fn renaming_preserves_the_fields_the_form_never_carries() {
9805        let did = "did:plc:renamer4";
9806        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
9807        let state = test_state_with_sidecar(&[did], &sidecar).await;
9808        let cookie = session_cookie(&state, did, None);
9809
9810        let resp = router(state.clone())
9811            .oneshot(
9812                Request::builder()
9813                    .method("POST")
9814                    .uri("/subscriptions/rk-keep/rename")
9815                    .header(header::COOKIE, cookie)
9816                    .header("content-type", "application/x-www-form-urlencoded")
9817                    // Exactly what the manage row posts: url, title, folder.
9818                    .body(Body::from(
9819                        "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=New+title&folder=Tech",
9820                    ))
9821                    .unwrap(),
9822            )
9823            .await
9824            .unwrap();
9825        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9826
9827        let bodies = puts.lock().unwrap().clone();
9828        assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
9829        let body = &bodies[0];
9830        // Anchors the negative assertions: an empty capture would satisfy them.
9831        assert!(
9832            body.contains("community.lexicon.rss.subscription"),
9833            "captured no usable put body: {body:?}"
9834        );
9835
9836        let sent: serde_json::Value = serde_json::from_str(body).expect("put body is JSON");
9837        let record = &sent["record"];
9838
9839        // What the form DID carry must be applied.
9840        assert_eq!(record["title"], "New title", "the rename did not apply");
9841        assert_eq!(record["folder"], "Tech", "the re-folder did not apply");
9842
9843        // What the form did NOT carry must survive.
9844        assert_eq!(
9845            record["createdAt"], "2024-03-01T00:00:00.000Z",
9846            "the rename reset createdAt — the reader's subscribe time is gone \
9847             from their own repo, and nothing told them"
9848        );
9849        assert_eq!(
9850            record["siteUrl"], "https://example.com/blog",
9851            "the rename erased siteUrl"
9852        );
9853        assert_eq!(record["fetchHint"], "hourly", "the rename erased fetchHint");
9854        assert_eq!(record["private"], false, "the rename erased private");
9855    }
9856
9857    /// **Repointing at a different feed drops that feed's properties, but not
9858    /// the subscription's.**
9859    ///
9860    /// `siteUrl` and `fetchHint` describe the feed the subscription points at,
9861    /// so carrying them onto a different URL would leave a site link for the old
9862    /// feed hanging off the new one. `createdAt` and `private` are properties of
9863    /// the SUBSCRIPTION and survive a repoint — the reader subscribed when they
9864    /// subscribed, whatever the URL was later corrected to.
9865    #[tokio::test]
9866    async fn repointing_a_feed_drops_the_old_feeds_properties_but_keeps_the_subscriptions() {
9867        let did = "did:plc:renamer4";
9868        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
9869        let state = test_state_with_sidecar(&[did], &sidecar).await;
9870        let cookie = session_cookie(&state, did, None);
9871
9872        let resp = router(state.clone())
9873            .oneshot(
9874                Request::builder()
9875                    .method("POST")
9876                    .uri("/subscriptions/rk-keep/rename")
9877                    .header(header::COOKIE, cookie)
9878                    .header("content-type", "application/x-www-form-urlencoded")
9879                    // A DIFFERENT feed URL from the seeded record.
9880                    .body(Body::from(
9881                        "url=https%3A%2F%2Fother.example%2Ffeed.xml&title=Repointed",
9882                    ))
9883                    .unwrap(),
9884            )
9885            .await
9886            .unwrap();
9887        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9888
9889        let bodies = puts.lock().unwrap().clone();
9890        assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
9891        assert!(
9892            bodies[0].contains("community.lexicon.rss.subscription"),
9893            "captured no usable put body: {:?}",
9894            bodies[0]
9895        );
9896        let sent: serde_json::Value = serde_json::from_str(&bodies[0]).expect("put body is JSON");
9897        let record = &sent["record"];
9898
9899        assert_eq!(record["url"], "https://other.example/feed.xml");
9900        // The old feed's properties are gone rather than misattributed.
9901        assert!(
9902            record.get("siteUrl").is_none() || record["siteUrl"].is_null(),
9903            "the old feed's site link followed the subscription to a new feed: {record}"
9904        );
9905        assert!(
9906            record.get("fetchHint").is_none() || record["fetchHint"].is_null(),
9907            "the old feed's fetch hint followed the subscription to a new feed: {record}"
9908        );
9909        // The subscription's own properties survive.
9910        assert_eq!(
9911            record["createdAt"], "2024-03-01T00:00:00.000Z",
9912            "a repoint is still not a new subscription; createdAt must not move"
9913        );
9914        assert_eq!(record["private"], false, "the repoint erased private");
9915    }
9916
9917    /// **A rename against an rkey that is not in the repo writes NOTHING.**
9918    ///
9919    /// `update_subscription` is a `putRecord`, which CREATES the record when the
9920    /// rkey does not exist — with whatever `createdAt` we hand it. So without
9921    /// this refusal a rename against a stale or wrong rkey manufactures a
9922    /// subscription dated today, which is the bug this whole change exists to
9923    /// fix, arriving by a different door.
9924    ///
9925    /// The guard was untested when first written: removing it left all 733 tests
9926    /// green. An untested guard against the exact defect being fixed is how the
9927    /// two previous rounds of this problem got through.
9928    #[tokio::test]
9929    async fn renaming_an_unknown_rkey_writes_nothing() {
9930        let did = "did:plc:renamer4";
9931        // The sidecar serves exactly one record, at rkey `rk-keep`.
9932        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
9933        let state = test_state_with_sidecar(&[did], &sidecar).await;
9934        let cookie = session_cookie(&state, did, None);
9935
9936        let resp = router(state.clone())
9937            .oneshot(
9938                Request::builder()
9939                    .method("POST")
9940                    // ...and this is not it.
9941                    .uri("/subscriptions/rk-does-not-exist/rename")
9942                    .header(header::COOKIE, cookie)
9943                    .header("content-type", "application/x-www-form-urlencoded")
9944                    .body(Body::from(
9945                        "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=Ghost",
9946                    ))
9947                    .unwrap(),
9948            )
9949            .await
9950            .unwrap();
9951
9952        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9953        let loc = resp
9954            .headers()
9955            .get(header::LOCATION)
9956            .unwrap()
9957            .to_str()
9958            .unwrap();
9959        assert!(
9960            loc.contains("flash="),
9961            "an unknown rkey redirected as though the rename had worked: {loc}"
9962        );
9963        assert!(
9964            puts.lock().unwrap().is_empty(),
9965            "a rename against an unknown rkey wrote a record — putRecord would \
9966             CREATE it, dated today: {:?}",
9967            puts.lock().unwrap()
9968        );
9969    }
9970
9971    /// **A `site_url` the client actually sends is applied, not dropped.**
9972    ///
9973    /// `templates/manage_row.html` does not post this field, so it is tempting
9974    /// to read the arm that handles it as dead code. It is not:
9975    /// `RenameSubForm` carries `site_url`, so a hand-crafted POST reaches it
9976    /// today. Discarding the value instead of applying it left all 733 tests
9977    /// green.
9978    ///
9979    /// The value is scheme-checked on the way out by the repo-boundary vet, so
9980    /// this is a coverage gap rather than an exposure — but an untested path
9981    /// that writes a URL into the reader's PDS should not stay untested.
9982    #[tokio::test]
9983    async fn a_client_supplied_site_url_reaches_the_record() {
9984        let did = "did:plc:renamer4";
9985        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
9986        let state = test_state_with_sidecar(&[did], &sidecar).await;
9987        let cookie = session_cookie(&state, did, None);
9988
9989        let resp = router(state.clone())
9990            .oneshot(
9991                Request::builder()
9992                    .method("POST")
9993                    .uri("/subscriptions/rk-keep/rename")
9994                    .header(header::COOKIE, cookie)
9995                    .header("content-type", "application/x-www-form-urlencoded")
9996                    // Same feed URL, but carrying a site_url the manage row
9997                    // never sends.
9998                    .body(Body::from(
9999                        "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=Kept\
10000                         &site_url=https%3A%2F%2Ftyped.example%2Fsite",
10001                    ))
10002                    .unwrap(),
10003            )
10004            .await
10005            .unwrap();
10006        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10007
10008        let bodies = puts.lock().unwrap().clone();
10009        assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
10010        assert!(
10011            bodies[0].contains("community.lexicon.rss.subscription"),
10012            "captured no usable put body: {:?}",
10013            bodies[0]
10014        );
10015        let sent: serde_json::Value = serde_json::from_str(&bodies[0]).expect("put body is JSON");
10016        assert_eq!(
10017            sent["record"]["siteUrl"], "https://typed.example/site",
10018            "the client's siteUrl was dropped; the seeded record's survived instead"
10019        );
10020    }
10021
10022    /// **A rename whose read fails writes NOTHING.**
10023    ///
10024    /// This is the property most easily lost when someone later touches this
10025    /// handler: falling back to `Subscription::new` on a read error looks like
10026    /// graceful degradation and is in fact the original bug, reinstated on
10027    /// exactly the path where it is hardest to notice. The reader must be told
10028    /// instead.
10029    #[tokio::test]
10030    async fn a_rename_whose_read_fails_writes_nothing() {
10031        let did = "did:plc:renamer5";
10032        // A port that accepts nothing: the read cannot succeed.
10033        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
10034        let dead = format!("http://{}", listener.local_addr().unwrap());
10035        drop(listener);
10036
10037        let state = test_state_with_sidecar(&[did], &dead).await;
10038        let cookie = session_cookie(&state, did, None);
10039        let before = store::count_feeds(&state.db).await.unwrap();
10040
10041        let resp = router(state.clone())
10042            .oneshot(
10043                Request::builder()
10044                    .method("POST")
10045                    .uri("/subscriptions/rk-keep/rename")
10046                    .header(header::COOKIE, cookie)
10047                    .header("content-type", "application/x-www-form-urlencoded")
10048                    .body(Body::from(
10049                        "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=Doomed",
10050                    ))
10051                    .unwrap(),
10052            )
10053            .await
10054            .unwrap();
10055
10056        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10057        let loc = resp
10058            .headers()
10059            .get(header::LOCATION)
10060            .unwrap()
10061            .to_str()
10062            .unwrap();
10063        assert!(
10064            loc.contains("flash="),
10065            "a failed read redirected as though the rename had worked: {loc}"
10066        );
10067        assert_eq!(
10068            store::count_feeds(&state.db).await.unwrap(),
10069            before,
10070            "a rename that could not read the record still wrote to the cache"
10071        );
10072    }
10073
10074    /// Folder pre-selection regression: the manage rename row must mark the
10075    /// feed's CURRENT folder `<option>` as `selected`, so an untouched Save
10076    /// re-submits the current folder instead of silently un-foldering the feed.
10077    /// A loose (un-foldered) feed must mark "No folder" selected instead. Renders
10078    /// `ManageTemplate` directly so no PDS/sidecar round-trip is needed.
10079    #[test]
10080    fn manage_rename_row_preselects_current_folder() {
10081        let nav = Nav {
10082            handle: "@reader.example".to_string(),
10083            avatar: "RE".to_string(),
10084            view: "unread".to_string(),
10085            scope_qs: String::new(),
10086            folders: Vec::new(),
10087            loose_feeds: Vec::new(),
10088            manage_active: true,
10089        };
10090        let folder_options = vec![
10091            FolderOption {
10092                uri: "at://did:plc:x/app.folder/work".to_string(),
10093                name: "Work".to_string(),
10094            },
10095            FolderOption {
10096                uri: "at://did:plc:x/app.folder/fun".to_string(),
10097                name: "Fun".to_string(),
10098            },
10099        ];
10100        // A foldered feed (in "Work") and a loose feed (no folder), each with a
10101        // non-empty rkey so the rename form renders.
10102        let foldered = FeedView {
10103            rkey: "sub-foldered".to_string(),
10104            url: "https://work.example/feed.xml".to_string(),
10105            title: "Work Feed".to_string(),
10106            unread: 0,
10107            selected: false,
10108            folder: Some("at://did:plc:x/app.folder/work".to_string()),
10109        };
10110        let loose = FeedView {
10111            rkey: "sub-loose".to_string(),
10112            url: "https://loose.example/feed.xml".to_string(),
10113            title: "Loose Feed".to_string(),
10114            unread: 0,
10115            selected: false,
10116            folder: None,
10117        };
10118        let tmpl = ManageTemplate {
10119            version: VERSION,
10120            repo_url: REPO_URL,
10121            kofi_url: KOFI_URL,
10122            flash: String::new(),
10123            nav,
10124            folder_options,
10125            folders: vec![FolderView {
10126                rkey: "folder-work".to_string(),
10127                uri: "at://did:plc:x/app.folder/work".to_string(),
10128                name: "Work".to_string(),
10129                feeds: vec![foldered],
10130                selected: false,
10131            }],
10132            loose_feeds: vec![loose],
10133        };
10134        let html = tmpl.render().unwrap();
10135
10136        // The foldered feed's "Work" option is pre-selected.
10137        assert!(
10138            html.contains(
10139                r#"<option value="at://did:plc:x/app.folder/work" selected>Work</option>"#
10140            ),
10141            "foldered feed must pre-select its current folder: {html}"
10142        );
10143        // The loose feed's "No folder" option is pre-selected (appears for the
10144        // loose row, which has folder=None).
10145        assert!(
10146            html.contains(r#"<option value="" selected>No folder</option>"#),
10147            "loose feed must pre-select 'No folder': {html}"
10148        );
10149    }
10150
10151    /// **The public stats page carries no user data.**
10152    ///
10153    /// It is reachable by anyone, so the thing worth pinning is what it does
10154    /// NOT say: nothing about how many people use the instance, nothing about
10155    /// which feeds fail, nothing about who reads what.
10156    #[tokio::test]
10157    async fn the_public_stats_page_exposes_no_user_data() {
10158        let state = test_state(&[]).await;
10159        store::ensure_seed(&state.db, &["did:plc:someone".to_string()])
10160            .await
10161            .unwrap();
10162
10163        let resp = router(state)
10164            .oneshot(
10165                Request::builder()
10166                    .uri("/stats")
10167                    .body(Body::empty())
10168                    .unwrap(),
10169            )
10170            .await
10171            .unwrap();
10172        assert_eq!(resp.status(), StatusCode::OK, "stats must be public");
10173
10174        let body = String::from_utf8(
10175            axum::body::to_bytes(resp.into_body(), usize::MAX)
10176                .await
10177                .unwrap()
10178                .to_vec(),
10179        )
10180        .unwrap();
10181
10182        // Structural checks, not word checks. The page's own prose says it
10183        // publishes no error rates, so searching for that PHRASE finds the
10184        // disclaimer rather than a leak — the first version of this test failed
10185        // on exactly that. What matters is whether identifiers or the
10186        // admin-only figures are present.
10187        assert!(
10188            !body.contains("did:"),
10189            "the public stats page leaked an identifier"
10190        );
10191        for admin_only in ["errp50ms", "p95ms", "live backend", "ok_count"] {
10192            assert!(
10193                !body.contains(admin_only),
10194                "the public page is showing the admin metrics column {admin_only:?}"
10195            );
10196        }
10197        // And it does render the aggregate it exists for.
10198        assert!(body.contains("Feeds tracked"));
10199        assert!(body.contains("Waiting to be polled"));
10200    }
10201
10202    /// **The two states that stop feeds updating must be visible.**
10203    ///
10204    /// `overdue` and `polled_last_hour` move in BOTH and distinguish neither —
10205    /// and `overdue` moves the WRONG WAY for backoff, because backoff is applied
10206    /// by pushing `next_poll` forward, so a feed failing every fetch drops out of
10207    /// the backlog and makes the page read healthier. That inversion is what this
10208    /// test pins: a broken feed must raise a number, not lower one.
10209    #[tokio::test]
10210    async fn stats_distinguishes_backoff_from_a_watermark_pause() {
10211        let state = test_state(&[]).await;
10212        // Three feeds: one healthy, one flaky, one long dead.
10213        for (url, errors) in [
10214            ("https://ok.example/f.xml", 0),
10215            ("https://flaky.example/f.xml", 2),
10216            ("https://dead.example/f.xml", 9),
10217        ] {
10218            store::upsert_feed(
10219                &state.db,
10220                &store::NewFeed {
10221                    url: url.to_string(),
10222                    // Pushed forward, exactly as backoff does — so none of these
10223                    // are counted as `overdue`.
10224                    next_poll: Some("2099-01-01T00:00:00Z".to_string()),
10225                    ..Default::default()
10226                },
10227            )
10228            .await
10229            .unwrap();
10230            for _ in 0..errors {
10231                store::bump_feed_errors(
10232                    &state.db,
10233                    url,
10234                    feed::FailureKind::Fetch,
10235                    "connection refused",
10236                )
10237                .await
10238                .unwrap();
10239            }
10240        }
10241
10242        let render_stats = |state: AppState| async move {
10243            let resp = router(state)
10244                .oneshot(
10245                    Request::builder()
10246                        .uri("/stats")
10247                        .body(Body::empty())
10248                        .unwrap(),
10249                )
10250                .await
10251                .unwrap();
10252            assert_eq!(resp.status(), StatusCode::OK);
10253            String::from_utf8(
10254                axum::body::to_bytes(resp.into_body(), usize::MAX)
10255                    .await
10256                    .unwrap()
10257                    .to_vec(),
10258            )
10259            .unwrap()
10260        };
10261
10262        // **The fixture must actually be RUNNING, or this test measures nothing.**
10263        // `test_state` leaves `schedulers_enabled` false, and `fetching_state`
10264        // checks that BEFORE the watermark — so without these two lines every
10265        // render below reports "off" and the watermark can never surface. The
10266        // assertions still passed, for reasons unrelated to what they name: see
10267        // the two comments below.
10268        state.runtime_health.set_schedulers_enabled(true);
10269        state
10270            .runtime_health
10271            .poll_tick_completed(crate::store::now_unix());
10272
10273        let body = render_stats(state.clone()).await;
10274        assert!(
10275            body.contains("Failing"),
10276            "backoff is still invisible on the public page"
10277        );
10278        // 2 failing, 1 of them badly (>= BADLY_BROKEN_ERRORS). Matched on the
10279        // value rather than on surrounding whitespace, so re-indenting the
10280        // template cannot break this.
10281        assert!(
10282            body.contains("2, 1 badly"),
10283            "expected '2, 1 badly' in the failing row; got:\n{}",
10284            body.split("Failing")
10285                .nth(1)
10286                .unwrap_or("")
10287                .chars()
10288                .take(300)
10289                .collect::<String>()
10290        );
10291        // Not paused, and the backlog is genuinely empty — which is exactly the
10292        // reading that used to be indistinguishable from healthy.
10293        //
10294        // **Asserted by EXCLUDING the other states, not by matching "running".**
10295        // The `off` row reads "the poller is not running on this instance", which
10296        // contains "running" — so the bare substring passed while the page was
10297        // reporting the exact opposite of what this line claims to check.
10298        assert!(
10299            !body.contains("the poller is not running")
10300                && !body.contains("the cache is at its size limit")
10301                && !body.contains("has not completed a round"),
10302            "expected the running state; the page reported a stopped one",
10303        );
10304
10305        // Now trip the watermark. Nothing in the database changes; only the
10306        // recorded runtime state does — which is the whole reason it needed a
10307        // home outside the log stream.
10308        state.runtime_health.set_watermark(true);
10309        let paused = render_stats(state.clone()).await;
10310        // Matched on the paused row's OWN sentence. The bare word "paused" also
10311        // appeared in the page's explanatory prose, so this assertion passed
10312        // whether or not the row rendered — and trimming that prose is what
10313        // exposed it. This phrase exists only inside the `paused` branch.
10314        assert!(
10315            paused.contains("the cache is at its size limit"),
10316            "a watermark pause is still invisible on the public page"
10317        );
10318
10319        // Still no identifiers: these are counts, not feeds.
10320        for leak in ["ok.example", "flaky.example", "dead.example", "did:"] {
10321            assert!(
10322                !paused.contains(leak),
10323                "the public page leaked {leak:?} while reporting failures"
10324            );
10325        }
10326    }
10327
10328    /// **`/admin/metrics` is gated, and nothing checked that it was.**
10329    ///
10330    /// Deleting the `admin_seed_dids` check left the entire suite green. That
10331    /// was survivable while the page held only aggregate timings; it is not now,
10332    /// because this branch puts **per-feed URLs and remote error text** behind
10333    /// that gate. A guarantee nothing checks is a comment, and this one is now
10334    /// the only thing standing between a signed-in stranger and the operational
10335    /// picture the handler's own doc says is not public.
10336    ///
10337    /// All three doors: no session, a session that is not an admin, and the
10338    /// admin itself.
10339    #[tokio::test]
10340    async fn admin_metrics_is_refused_to_everyone_but_an_admin() {
10341        let admin = "did:plc:adminseed";
10342        // **Only the admin is in ALLOWED_DIDS**, because `admin_seed_dids()`
10343        // IS that list — deliberately, per its doc: "the same people I trust on
10344        // this instance". Production sets it to the bootstrap DID alone.
10345        //
10346        // A genuine non-admin is therefore someone holding a beta seat granted
10347        // by an invite, not by the allow-list. Seeding both would have made
10348        // both admins and quietly turned the 403 assertion below into a test of
10349        // nothing — which is exactly what the first draft of this did.
10350        let state = test_state(&[admin]).await;
10351        store::grant_access(&state.db, "did:plc:ordinaryuser", None, "invite", None)
10352            .await
10353            .unwrap();
10354        let url = "https://broken.example/f.xml";
10355        store::upsert_feed(
10356            &state.db,
10357            &store::NewFeed {
10358                url: url.to_string(),
10359                ..Default::default()
10360            },
10361        )
10362        .await
10363        .unwrap();
10364        store::bump_feed_errors(
10365            &state.db,
10366            url,
10367            feed::FailureKind::Fetch,
10368            "SENTINEL_ADMIN_ONLY",
10369        )
10370        .await
10371        .unwrap();
10372
10373        let get = |state: AppState, cookie: Option<String>| async move {
10374            let mut req = Request::builder().uri("/admin/metrics");
10375            if let Some(c) = cookie {
10376                req = req.header(header::COOKIE, c);
10377            }
10378            let resp = router(state)
10379                .oneshot(req.body(Body::empty()).unwrap())
10380                .await
10381                .unwrap();
10382            let status = resp.status();
10383            let body = String::from_utf8(
10384                axum::body::to_bytes(resp.into_body(), usize::MAX)
10385                    .await
10386                    .unwrap()
10387                    .to_vec(),
10388            )
10389            .unwrap();
10390            (status, body)
10391        };
10392
10393        // No session at all.
10394        let (status, body) = get(state.clone(), None).await;
10395        assert_eq!(status, StatusCode::UNAUTHORIZED);
10396        assert!(
10397            !body.contains("SENTINEL_ADMIN_ONLY"),
10398            "leaked to anonymous: {body}"
10399        );
10400
10401        // A real, signed-in user who is not an admin.
10402        let ordinary = session_cookie(&state, "did:plc:ordinaryuser", None);
10403        let (status, body) = get(state.clone(), Some(ordinary)).await;
10404        assert_eq!(
10405            status,
10406            StatusCode::FORBIDDEN,
10407            "a non-admin session was let in"
10408        );
10409        assert!(
10410            !body.contains("SENTINEL_ADMIN_ONLY") && !body.contains("broken.example"),
10411            "leaked to a non-admin: {body}",
10412        );
10413
10414        // The admin does get it — otherwise the two refusals above are
10415        // satisfied by the endpoint being broken for everyone.
10416        let admin_cookie = session_cookie(&state, admin, None);
10417        let (status, body) = get(state, Some(admin_cookie)).await;
10418        assert_eq!(status, StatusCode::OK);
10419        assert!(
10420            body.contains("SENTINEL_ADMIN_ONLY"),
10421            "admin cannot see it: {body}"
10422        );
10423    }
10424
10425    /// **The cause a public count cannot carry belongs on the admin page.**
10426    ///
10427    /// The public histogram is four coarse buckets, and `fetch` is the coarsest:
10428    /// #159's own error — `guarded_get` bailing on a 304 — lands there beside
10429    /// DNS failure, timeout and SSRF refusal. So the histogram alone would NOT
10430    /// have separated "sixty dead publishers" from "one bug here", which is the
10431    /// case it was justified by.
10432    ///
10433    /// The answer is not a finer public vocabulary — `/stats` promises never
10434    /// which feed and never whose, and a bucket per error string would break
10435    /// that. It is to put the detail where per-feed data is already allowed.
10436    /// `/admin/metrics` is gated on `ALLOWED_DIDS` and already carries an
10437    /// operational picture.
10438    ///
10439    /// Asserts both halves: the detail IS on the admin page, and is NOT on the
10440    /// public one.
10441    #[tokio::test]
10442    async fn the_admin_page_names_failing_feeds_and_the_public_page_does_not() {
10443        let admin = "did:plc:adminseed";
10444        let state = test_state(&[admin]).await;
10445        let url = "https://broken.example/f.xml";
10446        store::upsert_feed(
10447            &state.db,
10448            &store::NewFeed {
10449                url: url.to_string(),
10450                ..Default::default()
10451            },
10452        )
10453        .await
10454        .unwrap();
10455        store::bump_feed_errors(
10456            &state.db,
10457            url,
10458            feed::FailureKind::Fetch,
10459            "SENTINEL_REDIRECT_NO_LOCATION",
10460        )
10461        .await
10462        .unwrap();
10463
10464        let cookie = session_cookie(&state, admin, None);
10465        let resp = router(state.clone())
10466            .oneshot(
10467                Request::builder()
10468                    .uri("/admin/metrics")
10469                    .header(header::COOKIE, cookie)
10470                    .body(Body::empty())
10471                    .unwrap(),
10472            )
10473            .await
10474            .unwrap();
10475        assert_eq!(resp.status(), StatusCode::OK);
10476        let admin_body = String::from_utf8(
10477            axum::body::to_bytes(resp.into_body(), usize::MAX)
10478                .await
10479                .unwrap()
10480                .to_vec(),
10481        )
10482        .unwrap();
10483        assert!(
10484            admin_body.contains("SENTINEL_REDIRECT_NO_LOCATION"),
10485            "the admin page does not carry the failure detail: {admin_body}",
10486        );
10487        assert!(
10488            admin_body.contains("broken.example"),
10489            "the admin page does not name the failing feed: {admin_body}",
10490        );
10491
10492        // The public page still carries neither.
10493        let resp = router(state)
10494            .oneshot(
10495                Request::builder()
10496                    .uri("/stats")
10497                    .body(Body::empty())
10498                    .unwrap(),
10499            )
10500            .await
10501            .unwrap();
10502        let public = String::from_utf8(
10503            axum::body::to_bytes(resp.into_body(), usize::MAX)
10504                .await
10505                .unwrap()
10506                .to_vec(),
10507        )
10508        .unwrap();
10509        for secret in ["SENTINEL_REDIRECT_NO_LOCATION", "broken.example"] {
10510            assert!(
10511                !public.contains(secret),
10512                "{secret:?} reached the PUBLIC stats page: {public}",
10513            );
10514        }
10515    }
10516
10517    /// **A direct poll must settle the error columns, like the scheduler does.**
10518    ///
10519    /// `add_subscription` polls through `feed::poll_feed` rather than the
10520    /// scheduler, and `poll_feed` writes validators and `last_polled` but never
10521    /// touches `consecutive_errors` — that is the scheduler's job, and this path
10522    /// is not the scheduler.
10523    ///
10524    /// So a feed that was failing, is re-subscribed, and polls SUCCESSFULLY kept
10525    /// its old count and its old cause: the public page went on reporting it
10526    /// under `Failing`, under `badly_broken`, and under a cause, for as long as
10527    /// the stale backoff horizon lasted — up to 24h — while the reader was
10528    /// demonstrably fetching it.
10529    #[tokio::test]
10530    async fn a_successful_direct_poll_clears_a_stale_failure() {
10531        let state = test_state(&[]).await;
10532        let url = "https://recovered.example/f.xml";
10533        store::upsert_feed(
10534            &state.db,
10535            &store::NewFeed {
10536                url: url.to_string(),
10537                ..Default::default()
10538            },
10539        )
10540        .await
10541        .unwrap();
10542        store::bump_feed_errors(&state.db, url, feed::FailureKind::Fetch, "SENTINEL_OLD")
10543            .await
10544            .unwrap();
10545        // Park it on a stale backoff horizon, as a real failing feed would be.
10546        sqlx::query("UPDATE feeds SET next_poll = '2099-01-01T00:00:00Z' WHERE url = ?1")
10547            .bind(url)
10548            .execute(&state.db)
10549            .await
10550            .unwrap();
10551
10552        // The publisher is fixed: a successful poll happens on this path.
10553        feed::settle_poll(
10554            &state.db,
10555            url,
10556            &feed::PollOutcome::NotModified,
10557            state.config.poll_interval,
10558        )
10559        .await;
10560
10561        let row: (i64, Option<String>, Option<String>) = sqlx::query_as(
10562            "SELECT consecutive_errors, last_error_kind, next_poll FROM feeds WHERE url = ?1",
10563        )
10564        .bind(url)
10565        .fetch_one(&state.db)
10566        .await
10567        .unwrap();
10568        assert_eq!(row.0, 0, "a successful direct poll left the error streak");
10569        assert_eq!(row.1, None, "a successful direct poll left a stale cause");
10570        // **The half the first fix missed.** Clearing the count fixed the
10571        // REPORTING; the feed stayed parked until 2099. A working feed must be
10572        // rescheduled on its normal cadence, not left on the failure horizon.
10573        let next = row.2.expect("next_poll was cleared to NULL");
10574        // Not merely "moved off 2099" — rescheduled on the CADENCE, not a
10575        // backoff. A mutation that reschedules successes with backoff_for(1)
10576        // (5 min) also moves it off 2099, so the interval is asserted.
10577        let parsed = chrono::DateTime::parse_from_rfc3339(&next).unwrap();
10578        let delta = parsed
10579            .signed_duration_since(chrono::Utc::now())
10580            .num_seconds();
10581        let cadence = state.config.poll_interval.as_secs() as i64;
10582        assert!(
10583            (cadence - 60..=cadence + 60).contains(&delta),
10584            "expected rescheduling on the {cadence}s cadence, got {delta}s (next_poll={next})"
10585        );
10586    }
10587
10588    /// The mirror case: a first poll that FAILS must be visible at all.
10589    ///
10590    /// `Ok(outcome) => info!(...)` discarded a `PollOutcome::Failed`, so a
10591    /// subscription whose very first fetch failed sat at `consecutive_errors = 0`
10592    /// with a NULL cause — invisible to the page built to count exactly that.
10593    #[tokio::test]
10594    async fn a_failing_direct_poll_is_recorded() {
10595        let state = test_state(&[]).await;
10596        let url = "https://born-broken.example/f.xml";
10597        store::upsert_feed(
10598            &state.db,
10599            &store::NewFeed {
10600                url: url.to_string(),
10601                ..Default::default()
10602            },
10603        )
10604        .await
10605        .unwrap();
10606
10607        feed::settle_poll(
10608            &state.db,
10609            url,
10610            &feed::PollOutcome::Failed {
10611                backoff: std::time::Duration::from_secs(300),
10612                kind: feed::FailureKind::Parse,
10613                detail: "SENTINEL_BORN_BROKEN".to_string(),
10614            },
10615            state.config.poll_interval,
10616        )
10617        .await;
10618
10619        let row: (i64, Option<String>, Option<String>) = sqlx::query_as(
10620            "SELECT consecutive_errors, last_error_kind, next_poll FROM feeds WHERE url = ?1",
10621        )
10622        .bind(url)
10623        .fetch_one(&state.db)
10624        .await
10625        .unwrap();
10626        assert_eq!(row.0, 1, "a failed first poll was not counted");
10627        assert_eq!(
10628            row.1.as_deref(),
10629            Some("parse"),
10630            "its cause was not recorded"
10631        );
10632        // And it is BACKED OFF on the schedule the scheduler would use — not
10633        // left with a NULL next_poll that `due_feeds` sorts first and re-polls
10634        // on the very next tick.
10635        let next = row.2.expect("a failed direct poll left next_poll NULL");
10636        let parsed = chrono::DateTime::parse_from_rfc3339(&next).unwrap();
10637        let delta = parsed
10638            .signed_duration_since(chrono::Utc::now())
10639            .num_seconds();
10640        assert!(
10641            (240..=360).contains(&delta),
10642            "expected ~300s backoff after one failure, got {delta}s (next_poll={next})"
10643        );
10644    }
10645
10646    /// **The breakdown must sum to the Failing figure above it.**
10647    ///
10648    /// The histogram counts `last_error_kind IS NOT NULL`; `Failing` counts
10649    /// `consecutive_errors > 0`. On a migrated database every row that was
10650    /// already failing has a NULL kind — correctly, it was never recorded — so
10651    /// the two do not reconcile and the page shows "70 failing" beside "3
10652    /// fetch" with 67 silently unaccounted for. On deploy day the row vanishes
10653    /// entirely while the prose still promises a breakdown.
10654    ///
10655    /// An explicit `unknown` bucket is the honest shape: the page says how many
10656    /// it cannot explain rather than omitting them.
10657    #[tokio::test]
10658    async fn the_failure_breakdown_accounts_for_every_failing_feed() {
10659        let state = test_state(&[]).await;
10660        // Two legacy rows: failing, with no recorded cause.
10661        for url in [
10662            "https://legacy1.example/f.xml",
10663            "https://legacy2.example/f.xml",
10664        ] {
10665            store::upsert_feed(
10666                &state.db,
10667                &store::NewFeed {
10668                    url: url.to_string(),
10669                    next_poll: Some("2099-01-01T00:00:00Z".to_string()),
10670                    ..Default::default()
10671                },
10672            )
10673            .await
10674            .unwrap();
10675            sqlx::query("UPDATE feeds SET consecutive_errors = 4 WHERE url = ?1")
10676                .bind(url)
10677                .execute(&state.db)
10678                .await
10679                .unwrap();
10680        }
10681        // One row with a recorded cause.
10682        store::upsert_feed(
10683            &state.db,
10684            &store::NewFeed {
10685                url: "https://known.example/f.xml".to_string(),
10686                next_poll: Some("2099-01-01T00:00:00Z".to_string()),
10687                ..Default::default()
10688            },
10689        )
10690        .await
10691        .unwrap();
10692        store::bump_feed_errors(
10693            &state.db,
10694            "https://known.example/f.xml",
10695            feed::FailureKind::Status,
10696            "SENTINEL",
10697        )
10698        .await
10699        .unwrap();
10700
10701        let now = chrono::Utc::now();
10702        let health = store::poll_health(
10703            &state.db,
10704            &now.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
10705            &(now - chrono::Duration::hours(1)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
10706        )
10707        .await
10708        .unwrap();
10709        let counted: i64 = health.failure_kinds.iter().map(|(_, n)| n).sum();
10710        assert_eq!(
10711            counted, health.in_backoff,
10712            "the breakdown ({counted}) does not account for all {} failing feeds: {:?}",
10713            health.in_backoff, health.failure_kinds,
10714        );
10715        assert!(
10716            health
10717                .failure_kinds
10718                .iter()
10719                .any(|(k, n)| k == "unknown" && *n == 2),
10720            "no unknown bucket for the legacy rows: {:?}",
10721            health.failure_kinds,
10722        );
10723    }
10724
10725    /// **The breakdown is ordered by count, and the assertion can see it.**
10726    ///
10727    /// The first version of this asserted with three `contains` calls, which
10728    /// cannot observe order — deleting `ORDER BY` from the query passed.
10729    #[tokio::test]
10730    async fn the_failure_breakdown_is_ordered_by_count() {
10731        let state = test_state(&[]).await;
10732        for (url, kind, n) in [
10733            ("https://p1.example/f.xml", feed::FailureKind::Parse, 1),
10734            ("https://f1.example/f.xml", feed::FailureKind::Fetch, 1),
10735            ("https://f2.example/f.xml", feed::FailureKind::Fetch, 1),
10736            ("https://f3.example/f.xml", feed::FailureKind::Fetch, 1),
10737            ("https://s1.example/f.xml", feed::FailureKind::Status, 1),
10738            ("https://s2.example/f.xml", feed::FailureKind::Status, 1),
10739        ] {
10740            store::upsert_feed(
10741                &state.db,
10742                &store::NewFeed {
10743                    url: url.to_string(),
10744                    next_poll: Some("2099-01-01T00:00:00Z".to_string()),
10745                    ..Default::default()
10746                },
10747            )
10748            .await
10749            .unwrap();
10750            for _ in 0..n {
10751                store::bump_feed_errors(&state.db, url, kind, "d")
10752                    .await
10753                    .unwrap();
10754            }
10755        }
10756        let now = chrono::Utc::now();
10757        let health = store::poll_health(
10758            &state.db,
10759            &now.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
10760            &(now - chrono::Duration::hours(1)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
10761        )
10762        .await
10763        .unwrap();
10764        let labels: Vec<&str> = health
10765            .failure_kinds
10766            .iter()
10767            .map(|(k, _)| k.as_str())
10768            .collect();
10769        assert_eq!(
10770            labels,
10771            ["fetch", "status", "parse"],
10772            "not ordered by count, descending: {:?}",
10773            health.failure_kinds,
10774        );
10775    }
10776
10777    /// **Failing feeds are grouped by CAUSE, and still never named.**
10778    ///
10779    /// `badly_broken` could say that sixty feeds were failing and not whether
10780    /// that was sixty dead publishers or one bug here. It was the latter — #159,
10781    /// a `304 Not Modified` read as a malformed redirect — and the page could
10782    /// not say so, which is most of why it went unexamined.
10783    ///
10784    /// The second half of this test is the constraint that shapes the first:
10785    /// `/stats` is public and promises machines-not-people, *never which feed
10786    /// and never whose*. A histogram of causes keeps that promise; a list of
10787    /// failing URLs would break it, and is the obvious way to build this.
10788    #[tokio::test]
10789    async fn stats_groups_failures_by_cause_without_naming_any_feed() {
10790        let state = test_state(&[]).await;
10791        for (url, kind, detail, errors) in [
10792            // Detail strings are distinctive SENTINELS, not plausible English.
10793            // A first pass used "not a feed", which the page's own explanation
10794            // of the `parse` kind contains verbatim — the privacy assertion
10795            // fired on static copy rather than on a leak. A sentinel cannot
10796            // collide with prose.
10797            (
10798                "https://a.example/f.xml",
10799                feed::FailureKind::Fetch,
10800                "SENTINEL_CONNREFUSED",
10801                3,
10802            ),
10803            (
10804                "https://b.example/f.xml",
10805                feed::FailureKind::Fetch,
10806                "SENTINEL_DNSFAIL",
10807                2,
10808            ),
10809            (
10810                "https://c.example/f.xml",
10811                feed::FailureKind::Status,
10812                "SENTINEL_404",
10813                1,
10814            ),
10815            (
10816                "https://d.example/f.xml",
10817                feed::FailureKind::Parse,
10818                "SENTINEL_UNPARSEABLE",
10819                1,
10820            ),
10821        ] {
10822            store::upsert_feed(
10823                &state.db,
10824                &store::NewFeed {
10825                    url: url.to_string(),
10826                    next_poll: Some("2099-01-01T00:00:00Z".to_string()),
10827                    ..Default::default()
10828                },
10829            )
10830            .await
10831            .unwrap();
10832            for _ in 0..errors {
10833                store::bump_feed_errors(&state.db, url, kind, detail)
10834                    .await
10835                    .unwrap();
10836            }
10837        }
10838
10839        let resp = router(state.clone())
10840            .oneshot(
10841                Request::builder()
10842                    .uri("/stats")
10843                    .body(Body::empty())
10844                    .unwrap(),
10845            )
10846            .await
10847            .unwrap();
10848        assert_eq!(resp.status(), StatusCode::OK);
10849        let body = String::from_utf8(
10850            axum::body::to_bytes(resp.into_body(), usize::MAX)
10851                .await
10852                .unwrap()
10853                .to_vec(),
10854        )
10855        .unwrap();
10856
10857        // Descending by count: two fetch, then one each, tie-broken by name.
10858        assert!(
10859            body.contains("2 fetch") && body.contains("1 status") && body.contains("1 parse"),
10860            "the cause histogram did not render: {body}",
10861        );
10862
10863        // **The privacy half.** No feed URL, host, or error detail reaches the
10864        // public page — only counts by kind.
10865        for secret in [
10866            "a.example",
10867            "b.example",
10868            "c.example",
10869            "d.example",
10870            "SENTINEL_CONNREFUSED",
10871            "SENTINEL_DNSFAIL",
10872            "SENTINEL_404",
10873            "SENTINEL_UNPARSEABLE",
10874        ] {
10875            assert!(
10876                !body.contains(secret),
10877                "{secret:?} reached the PUBLIC stats page: {body}",
10878            );
10879        }
10880    }
10881
10882    /// `/health` must prove the process can reach its database, and must report
10883    /// the loop state without letting it change the status code.
10884    #[tokio::test]
10885    async fn health_checks_the_database_and_reports_the_loops() {
10886        let state = test_state(&[]).await;
10887        let body_of = |state: AppState| async move {
10888            let resp = router(state)
10889                .oneshot(
10890                    Request::builder()
10891                        .uri("/health")
10892                        .body(Body::empty())
10893                        .unwrap(),
10894                )
10895                .await
10896                .unwrap();
10897            let status = resp.status();
10898            let body = String::from_utf8(
10899                axum::body::to_bytes(resp.into_body(), usize::MAX)
10900                    .await
10901                    .unwrap()
10902                    .to_vec(),
10903            )
10904            .unwrap();
10905            (status, body)
10906        };
10907
10908        // The boot stamp is what `main` sets; the router alone does not, so this
10909        // starts "unknown" and the uptime branch below drives it explicitly.
10910        state
10911            .runtime_health
10912            .set_started_at(chrono::Utc::now().timestamp());
10913
10914        let (status, body) = body_of(state.clone()).await;
10915        assert_eq!(status, StatusCode::OK);
10916        assert!(
10917            body.contains("db: ok"),
10918            "health did not probe the DB: {body}"
10919        );
10920        assert!(
10921            body.contains("uptime:"),
10922            "no uptime — the first thing anyone asks about a container that may \
10923             be restarting: {body}"
10924        );
10925        assert!(body.contains("poller:"), "no scheduler heartbeat: {body}");
10926        assert!(body.contains("polling-paused: no"), "{body}");
10927        assert!(body.contains("backend:"), "{body}");
10928        assert!(body.contains("oauth-runtime:"), "{body}");
10929
10930        // A watermark pause is REPORTED but must not fail the check. A failed
10931        // check DEREGISTERS this machine from the proxy — and it is the only
10932        // machine — so it would turn "feeds are behind" into "the site is down"
10933        // for as long as the disk stays full.
10934        state.runtime_health.set_watermark(true);
10935        state.runtime_health.set_schedulers_enabled(true);
10936        let (status, body) = body_of(state.clone()).await;
10937        assert_eq!(
10938            status,
10939            StatusCode::OK,
10940            "a watermark pause must not fail the liveness check: {body}"
10941        );
10942        assert!(body.contains("polling-paused: yes"), "{body}");
10943        // Schedulers on but no tick yet — and that must not read as "0s ago",
10944        // which is the healthiest possible answer to an unanswered question.
10945        assert!(
10946            body.contains("poller: not-yet-ticked"),
10947            "a never-ticked poller must say so: {body}"
10948        );
10949
10950        // A stale heartbeat is likewise reported, not fatal.
10951        let stale_after = health_tick_stale_secs(configured_poll_tick());
10952        let long_ago = chrono::Utc::now().timestamp() - (stale_after + 60);
10953        state.runtime_health.poll_tick_completed(long_ago);
10954        let (status, body) = body_of(state.clone()).await;
10955        assert_eq!(
10956            status,
10957            StatusCode::OK,
10958            "a stale poller must not 503: {body}"
10959        );
10960        assert!(body.contains("poller: stale"), "{body}");
10961
10962        // **A poller that has never ticked stops being benign.**
10963        //
10964        // In a crash loop with 30 s+ boot cycles the poller never reaches its
10965        // first tick, so `not-yet-ticked` was reported forever and the heartbeat
10966        // could not detect the one failure mode the startup delays were added
10967        // for. It is read against uptime now.
10968        state.runtime_health.poll_tick_completed(0); // reset to "never"
10969        state
10970            .runtime_health
10971            .set_started_at(chrono::Utc::now().timestamp() - (HEALTH_FIRST_TICK_GRACE_SECS + 60));
10972        let (status, body) = body_of(state.clone()).await;
10973        assert_eq!(status, StatusCode::OK);
10974        assert!(
10975            body.contains("poller: stale never-ticked"),
10976            "a poller that never ticked long after boot still reads as benign: {body}"
10977        );
10978
10979        // A closed pool is a real outage: nothing can be served, and a restart is
10980        // the correct response. THIS is what the status code is for.
10981        state.db.close().await;
10982        let (status, body) = body_of(state.clone()).await;
10983        assert_eq!(
10984            status,
10985            StatusCode::SERVICE_UNAVAILABLE,
10986            "an unreachable database must fail the check: {body}"
10987        );
10988        assert!(body.starts_with("FAIL"), "{body}");
10989        // Coarse, not the raw sqlx error: an unauthenticated caller learning
10990        // exactly which failure it hit is an attack-progress oracle, and this
10991        // endpoint is exempt from the origin lock.
10992        assert!(
10993            !body.contains("PoolClosed") && !body.contains("sqlx"),
10994            "health leaked the raw database error to an unauthenticated caller: {body}"
10995        );
10996    }
10997
10998    /// The staleness threshold must track the configured tick.
10999    ///
11000    /// Hardcoded at 15 minutes, an operator who raised
11001    /// `FEATHERREADER_POLL_TICK_SECS` above 900 got a permanent `poller: stale`
11002    /// in the body the deployment docs tell them to alert on.
11003    #[test]
11004    fn the_stale_threshold_follows_the_poll_tick() {
11005        // A fast tick keeps the floor — five 60 s ticks is 5 minutes, and
11006        // alerting that early would fire on any brief hiccup.
11007        assert_eq!(
11008            health_tick_stale_secs(Duration::from_secs(60)),
11009            HEALTH_TICK_STALE_FLOOR_SECS
11010        );
11011        // A slow tick raises it, so a legitimately-configured loop is never
11012        // permanently "stale".
11013        let slow = Duration::from_secs(30 * 60);
11014        assert!(
11015            health_tick_stale_secs(slow) > slow.as_secs() as i64,
11016            "a 30-minute tick must not be stale after one interval"
11017        );
11018        assert_eq!(health_tick_stale_secs(slow), 30 * 60 * 5);
11019        // And it cannot overflow into nonsense on an absurd value.
11020        assert!(health_tick_stale_secs(Duration::from_secs(u64::MAX)) > 0);
11021    }
11022
11023    /// `/stats` must distinguish "nothing is polling" from "polling is fine".
11024    ///
11025    /// `polling_paused` alone rendered "running" for three different states,
11026    /// including the two where nothing polls at all — on the page added to
11027    /// answer exactly that question.
11028    #[tokio::test]
11029    async fn stats_does_not_call_a_stopped_poller_running() {
11030        let state = test_state(&[]).await;
11031        let render = |state: AppState| async move {
11032            let resp = router(state)
11033                .oneshot(
11034                    Request::builder()
11035                        .uri("/stats")
11036                        .body(Body::empty())
11037                        .unwrap(),
11038                )
11039                .await
11040                .unwrap();
11041            assert_eq!(resp.status(), StatusCode::OK);
11042            String::from_utf8(
11043                axum::body::to_bytes(resp.into_body(), usize::MAX)
11044                    .await
11045                    .unwrap()
11046                    .to_vec(),
11047            )
11048            .unwrap()
11049        };
11050
11051        // Schedulers never started: not "running".
11052        let body = render(state.clone()).await;
11053        assert!(
11054            body.contains("the poller is not running on this instance"),
11055            "a disabled poller renders as healthy"
11056        );
11057
11058        // Started, but no tick has finished yet.
11059        state.runtime_health.set_schedulers_enabled(true);
11060        let body = render(state.clone()).await;
11061        assert!(
11062            body.contains("no poll has finished since this instance booted"),
11063            "a poller that has not ticked renders as healthy"
11064        );
11065
11066        // Ticking: running.
11067        state
11068            .runtime_health
11069            .poll_tick_completed(chrono::Utc::now().timestamp());
11070        let body = render(state.clone()).await;
11071        assert!(
11072            body.contains("running"),
11073            "a healthy poller must read as running"
11074        );
11075
11076        // Paused at the watermark still wins over "running".
11077        state.runtime_health.set_watermark(true);
11078        let body = render(state.clone()).await;
11079        assert!(
11080            body.contains("the cache is at its size limit"),
11081            "a watermark pause is hidden once the poller is ticking"
11082        );
11083    }
11084
11085    /// **An UNMEASURED database must not fail the check.**
11086    ///
11087    /// `/health` is the one path exempt from the Cloudflare origin lock and
11088    /// absent from the rate limiter, and `DbProbeGuard` releases its claim on
11089    /// drop WITHOUT recording a verdict — so a cancelled request (a client
11090    /// disconnect is enough) leaves the verdict at "none", and a concurrent
11091    /// caller reads it. Treating that as a failure turned an unauthenticated
11092    /// request into a lever on the only signal the platform acts on. The
11093    /// previous version of this code had the opposite bug and reported `ok` for
11094    /// a database nothing had read; "unknown" is neither.
11095    #[tokio::test]
11096    async fn health_reports_an_unmeasured_database_without_failing() {
11097        use crate::runtime_health::DbProbe;
11098        let state = test_state(&[]).await;
11099
11100        // Hold the probe claim, exactly as an in-flight request would, and never
11101        // record a verdict — the cancelled-request state.
11102        let held = state
11103            .runtime_health
11104            .begin_db_probe()
11105            .unwrap_or_else(|_| panic!("a fresh RuntimeHealth must grant the first claim"));
11106
11107        let resp = router(state.clone())
11108            .oneshot(
11109                Request::builder()
11110                    .uri("/health")
11111                    .body(Body::empty())
11112                    .unwrap(),
11113            )
11114            .await
11115            .unwrap();
11116        let status = resp.status();
11117        let body = String::from_utf8(
11118            axum::body::to_bytes(resp.into_body(), usize::MAX)
11119                .await
11120                .unwrap()
11121                .to_vec(),
11122        )
11123        .unwrap();
11124        drop(held);
11125
11126        assert_eq!(
11127            status,
11128            StatusCode::OK,
11129            "an unmeasured database failed the check, which an unauthenticated \
11130             caller can cause on demand: {body}"
11131        );
11132        assert!(
11133            body.contains("db: unknown"),
11134            "the unmeasured state must still be REPORTED: {body}"
11135        );
11136        assert!(!body.starts_with("FAIL"), "{body}");
11137        // **And it must not read as `ok` either.** `fly.toml` tells operators to
11138        // alert on the BODY for everything the status code ignores, so a first
11139        // line identical to the healthy one makes a monitor keying on `^ok` read
11140        // green in exactly the state this enum exists to surface.
11141        assert!(
11142            !body.starts_with("ok"),
11143            "the unmeasured state is indistinguishable from healthy to a \
11144             body-matching monitor: {body}"
11145        );
11146        assert!(body.starts_with("unknown"), "{body}");
11147
11148        // **A BORROWED failure must 503 too.**
11149        //
11150        // This previously recorded `Failed` and then closed the pool — but
11151        // `record` consumes the guard and releases the claim, so the request won
11152        // it, ran a live probe against the closed pool, and failed on its own.
11153        // The 503 passed for the wrong reason and the borrow path — the whole
11154        // point of the three-state enum on the read side — had no coverage.
11155        //
11156        // Holding the claim forces the borrow, so the recorded verdict is what
11157        // gets reported.
11158        let held = state
11159            .runtime_health
11160            .begin_db_probe()
11161            .unwrap_or_else(|_| panic!("claim"));
11162        state
11163            .runtime_health
11164            .record_for_test(DbProbe::Failed("unavailable".to_string()));
11165        let resp = router(state.clone())
11166            .oneshot(
11167                Request::builder()
11168                    .uri("/health")
11169                    .body(Body::empty())
11170                    .unwrap(),
11171            )
11172            .await
11173            .unwrap();
11174        let status = resp.status();
11175        let body = String::from_utf8(
11176            axum::body::to_bytes(resp.into_body(), usize::MAX)
11177                .await
11178                .unwrap()
11179                .to_vec(),
11180        )
11181        .unwrap();
11182        drop(held);
11183        assert_eq!(
11184            status,
11185            StatusCode::SERVICE_UNAVAILABLE,
11186            "a BORROWED failure verdict must fail the check, not just a freshly \
11187             measured one: {body}"
11188        );
11189        assert!(body.starts_with("FAIL"), "{body}");
11190
11191        state.db.close().await;
11192        let resp = router(state.clone())
11193            .oneshot(
11194                Request::builder()
11195                    .uri("/health")
11196                    .body(Body::empty())
11197                    .unwrap(),
11198            )
11199            .await
11200            .unwrap();
11201        assert_eq!(
11202            resp.status(),
11203            StatusCode::SERVICE_UNAVAILABLE,
11204            "a measured database failure must still fail the check"
11205        );
11206    }
11207
11208    /// **A disconnected client must not be able to cancel the probe.**
11209    ///
11210    /// Axum drops the handler future when a caller goes away. With the probe
11211    /// inline that dropped it mid-flight and released the claim WITHOUT
11212    /// recording a verdict — which let an unauthenticated caller manufacture the
11213    /// no-verdict state on demand and freeze what every other caller, including
11214    /// Fly's own check, reads. The probe runs detached now, so the verdict is
11215    /// recorded whatever happens to the request that started it.
11216    #[tokio::test]
11217    async fn an_abandoned_request_still_records_its_probe() {
11218        use crate::runtime_health::DbProbe;
11219        let state = test_state(&[]).await;
11220        let rh = state.runtime_health.clone();
11221
11222        // Drive /health and abandon it immediately — the disconnect case.
11223        let app = router(state.clone());
11224        let fut = app.oneshot(
11225            Request::builder()
11226                .uri("/health")
11227                .body(Body::empty())
11228                .unwrap(),
11229        );
11230        let handle = tokio::spawn(fut);
11231        handle.abort();
11232        let _ = handle.await;
11233
11234        // The detached probe still completes and publishes a verdict, so the
11235        // claim is free and the next caller gets a MEASURED answer.
11236        for _ in 0..50 {
11237            if rh.begin_db_probe().is_ok() {
11238                break;
11239            }
11240            tokio::time::sleep(Duration::from_millis(20)).await;
11241        }
11242        let resp = router(state.clone())
11243            .oneshot(
11244                Request::builder()
11245                    .uri("/health")
11246                    .body(Body::empty())
11247                    .unwrap(),
11248            )
11249            .await
11250            .unwrap();
11251        let body = String::from_utf8(
11252            axum::body::to_bytes(resp.into_body(), usize::MAX)
11253                .await
11254                .unwrap()
11255                .to_vec(),
11256        )
11257        .unwrap();
11258        assert!(
11259            body.contains("db: ok"),
11260            "after an abandoned request the next caller still reads an \
11261             unmeasured database — the probe was cancelled with it: {body}"
11262        );
11263        // Sanity: the type still distinguishes the three states.
11264        assert_ne!(DbProbe::Unknown, DbProbe::Ok);
11265    }
11266
11267    /// **The probe must read a real page.**
11268    ///
11269    /// `SELECT 1` compiles to `Init/Integer/ResultRow/Halt` — no `OpenRead`, so
11270    /// it never touches a b-tree and returns success against a corrupted
11271    /// database. Asserted by asking SQLite what the statement actually compiles
11272    /// to, so it survives someone "simplifying" the query later.
11273    #[tokio::test]
11274    async fn the_health_probe_opens_a_real_table() {
11275        use sqlx::Row;
11276        let state = test_state(&[]).await;
11277        // `EXPLAIN` lists the VM program; the `opcode` column is the second.
11278        let opcodes = |sql: &'static str| {
11279            let db = state.db.clone();
11280            async move {
11281                sqlx::query(sql)
11282                    .fetch_all(&db)
11283                    .await
11284                    .unwrap()
11285                    .into_iter()
11286                    .map(|r| r.get::<String, _>("opcode"))
11287                    .collect::<Vec<String>>()
11288            }
11289        };
11290
11291        // The statement `health_db_probe` really runs — it is the sole path, so
11292        // there is no second string for the handler to use instead.
11293        let explain: &'static str =
11294            Box::leak(format!("EXPLAIN {HEALTH_DB_PROBE_SQL}").into_boxed_str());
11295        let probe = opcodes(explain).await;
11296        // And the probe itself works against a real schema.
11297        assert!(
11298            health_db_probe(&state.db).await.is_ok(),
11299            "the probe does not run against the real schema",
11300        );
11301        assert!(
11302            probe.iter().any(|op| op == "OpenRead"),
11303            "the health probe reads no page; it cannot detect a broken database: {probe:?}"
11304        );
11305        // And the bare form genuinely does not, which is the whole point.
11306        let bare = opcodes("EXPLAIN SELECT 1").await;
11307        assert!(
11308            !bare.iter().any(|op| op == "OpenRead"),
11309            "premise check failed: bare SELECT 1 now reads a page: {bare:?}"
11310        );
11311    }
11312
11313    /// A fresh instance says "never", not "0" — which would read as "polled
11314    /// just now", the opposite of the truth.
11315    #[test]
11316    fn an_instance_that_has_never_polled_says_so() {
11317        assert_eq!(humanise_ago(None), "never");
11318        assert_eq!(humanise_ago(Some(0)), "0s ago");
11319        assert_eq!(humanise_ago(Some(59)), "59s ago");
11320        assert_eq!(humanise_ago(Some(60)), "1m ago");
11321        assert_eq!(humanise_ago(Some(3600)), "1h 0m ago");
11322        assert_eq!(humanise_ago(Some(11_460)), "3h 11m ago");
11323    }
11324
11325    /// A sidecar mock that answers `/internal/repo` listRecords with one saved
11326    /// record, and anything else with an empty list. Serves repeatedly.
11327    async fn spawn_saved_sidecar(saved_url: &str, saved_title: &str) -> String {
11328        use tokio::io::{AsyncReadExt, AsyncWriteExt};
11329        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
11330        let addr = listener.local_addr().unwrap();
11331        let (url, title) = (saved_url.to_string(), saved_title.to_string());
11332        tokio::spawn(async move {
11333            loop {
11334                let Ok((mut sock, _)) = listener.accept().await else {
11335                    break;
11336                };
11337                let mut buf = vec![0u8; 8192];
11338                let Ok(n) = sock.read(&mut buf).await else {
11339                    continue;
11340                };
11341                let req = String::from_utf8_lossy(&buf[..n]).to_string();
11342                let wants_saved = req.contains("community.lexicon.rss.saved");
11343                let records = if wants_saved {
11344                    serde_json::json!([{
11345                        "uri": "at://did:plc:x/community.lexicon.rss.saved/rk1",
11346                        "cid": "bafy",
11347                        "value": {
11348                            "$type": "community.lexicon.rss.saved",
11349                            "url": url,
11350                            "title": title,
11351                            "createdAt": "2026-01-01T00:00:00Z"
11352                        }
11353                    }])
11354                } else {
11355                    serde_json::json!([])
11356                };
11357                let body = serde_json::json!({
11358                    "ok": true, "data": { "records": records }
11359                })
11360                .to_string();
11361                let resp = format!(
11362                    "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
11363                    body.len(), body
11364                );
11365                let _ = sock.write_all(resp.as_bytes()).await;
11366                let _ = sock.flush().await;
11367            }
11368        });
11369        format!("http://{addr}")
11370    }
11371
11372    /// A sidecar mock serving `n` distinct saved records, none of them cached
11373    /// locally — the shape that exercises the uncached-row append.
11374    async fn spawn_saved_sidecar_many(n: usize, subscribed_feed: &str) -> String {
11375        let feed = subscribed_feed.to_string();
11376        use tokio::io::{AsyncReadExt, AsyncWriteExt};
11377        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
11378        let addr = listener.local_addr().unwrap();
11379        tokio::spawn(async move {
11380            loop {
11381                let Ok((mut sock, _)) = listener.accept().await else {
11382                    break;
11383                };
11384                let mut buf = vec![0u8; 8192];
11385                let Ok(read) = sock.read(&mut buf).await else {
11386                    continue;
11387                };
11388                let req = String::from_utf8_lossy(&buf[..read]).to_string();
11389                let records = if req.contains("community.lexicon.rss.saved") {
11390                    serde_json::Value::Array(
11391                        (0..n)
11392                            .map(|i| {
11393                                serde_json::json!({
11394                                    "uri": format!("at://did:plc:x/community.lexicon.rss.saved/rk{i}"),
11395                                    "cid": "bafy",
11396                                    "value": {
11397                                        "$type": "community.lexicon.rss.saved",
11398                                        "url": format!("https://elsewhere.example/{i}"),
11399                                        "title": format!("Elsewhere {i}"),
11400                                        "createdAt": "2026-01-01T00:00:00Z"
11401                                    }
11402                                })
11403                            })
11404                            .collect(),
11405                    )
11406                } else if req.contains("community.lexicon.rss.subscription") {
11407                    // Without this the handler's `sync_sub_refs` would REPLACE
11408                    // sub_ref with an empty set on every render, and every
11409                    // sub_ref-scoped read — including the cached starred list
11410                    // this test is about — would come back empty.
11411                    serde_json::json!([{
11412                        "uri": "at://did:plc:x/community.lexicon.rss.subscription/sub1",
11413                        "cid": "bafy",
11414                        "value": {
11415                            "$type": "community.lexicon.rss.subscription",
11416                            "url": feed,
11417                            "createdAt": "2026-01-01T00:00:00Z"
11418                        }
11419                    }])
11420                } else {
11421                    serde_json::json!([])
11422                };
11423                let body =
11424                    serde_json::json!({ "ok": true, "data": { "records": records } }).to_string();
11425                let resp = format!(
11426                    "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
11427                    body.len(), body
11428                );
11429                let _ = sock.write_all(resp.as_bytes()).await;
11430                let _ = sock.flush().await;
11431            }
11432        });
11433        format!("http://{addr}")
11434    }
11435
11436    /// **The pager must not advertise a page the clamp cannot reach.**
11437    ///
11438    /// The page clamp is computed from the CACHED total; the uncached PDS rows
11439    /// are appended to the last page rather than paged. Inflating `total` with
11440    /// them made `page_count` and the "Older →" link point one page past the end:
11441    /// requesting it clamped straight back, re-rendered the same last page, and
11442    /// still offered the link. An infinite "next" that never advances.
11443    #[tokio::test]
11444    async fn the_starred_pager_does_not_advertise_an_unreachable_page() {
11445        let did = "did:plc:pagerloop";
11446        let sidecar = spawn_saved_sidecar_many(80, "https://loop.example/feed.xml").await;
11447        let state = test_state_with_sidecar(&[], &sidecar).await;
11448        store::grant_access(&state.db, did, None, "test", None)
11449            .await
11450            .unwrap();
11451        let feed = store::upsert_feed(
11452            &state.db,
11453            &store::NewFeed {
11454                url: "https://loop.example/feed.xml".to_string(),
11455                title: Some("Loop".to_string()),
11456                ..Default::default()
11457            },
11458        )
11459        .await
11460        .unwrap();
11461        // 250 cached starred entries: the last page holds 50, so 50 + 80 > 100
11462        // and the old arithmetic reported a fourth page.
11463        let entries: Vec<store::NewEntry> = (0..250)
11464            .map(|i| store::NewEntry {
11465                guid: format!("s-{i:04}"),
11466                url: Some(format!("https://loop.example/{i}")),
11467                title: Some(format!("Starred {i:04}")),
11468                published: Some(format!("2026-06-{:02}T00:00:00Z", (i % 28) + 1)),
11469                ..Default::default()
11470            })
11471            .collect();
11472        store::insert_entries(&state.db, feed, &entries, 0)
11473            .await
11474            .unwrap();
11475        store::replace_sub_refs(&state.db, did, &[feed])
11476            .await
11477            .unwrap();
11478        for row in store::list_entries(&state.db, did, store::ListView::All, None, 1_000, 0)
11479            .await
11480            .unwrap()
11481        {
11482            store::mark_starred(&state.db, did, row.id, true)
11483                .await
11484                .unwrap();
11485        }
11486
11487        let cookie = session_cookie(&state, did, None);
11488        let app = router(state.clone());
11489        let get = |uri: &str| {
11490            let (app, cookie, uri) = (app.clone(), cookie.clone(), uri.to_string());
11491            async move {
11492                let resp = app
11493                    .oneshot(
11494                        Request::builder()
11495                            .uri(uri)
11496                            .header(header::COOKIE, cookie)
11497                            .body(Body::empty())
11498                            .unwrap(),
11499                    )
11500                    .await
11501                    .unwrap();
11502                assert_eq!(resp.status(), StatusCode::OK);
11503                String::from_utf8(
11504                    axum::body::to_bytes(resp.into_body(), 16 * 1024 * 1024)
11505                        .await
11506                        .unwrap()
11507                        .to_vec(),
11508                )
11509                .unwrap()
11510            }
11511        };
11512
11513        // 250 cached + 80 uncached = 330 rows over 4 pages. The pager and the
11514        // clamp must agree on that, and EVERY page it offers must have content —
11515        // the original bug advertised a fourth page that clamped back to the
11516        // third and re-rendered it, still offering the link.
11517        let p3 = get("/?view=starred&page=3").await;
11518        assert!(
11519            p3.contains("Page 3 of 4"),
11520            "the pager and the clamp disagree on the total: {}",
11521            p3.split("pager-pos")
11522                .nth(1)
11523                .unwrap_or("")
11524                .chars()
11525                .take(120)
11526                .collect::<String>()
11527        );
11528        // Page 3 is the boundary: the last 50 cached rows, then the first 50
11529        // uncached ones.
11530        assert!(
11531            p3.contains("Elsewhere 0"),
11532            "page 3 should start the uncached run"
11533        );
11534        assert_eq!(
11535            p3.matches("<li class=\"entry").count(),
11536            ENTRIES_PER_PAGE as usize,
11537            "the boundary page is not full"
11538        );
11539
11540        // **The heading, which the previous round broke by deleting this.**
11541        //
11542        // `total` includes the uncached records, so the parenthetical is a
11543        // SUBSET of it, not an addition — "330 entries (80 saved elsewhere)".
11544        // The version that said "plus N" double counted once `total` started
11545        // including them, and N had become page-local in the same commit while
11546        // the template stayed put. It shipped because this assertion was deleted
11547        // rather than updated.
11548        {
11549            let body = &p3;
11550            assert!(
11551                body.contains("330 entries"),
11552                "the heading must count the whole sequence: {}",
11553                body.split("content-count")
11554                    .nth(1)
11555                    .unwrap_or("")
11556                    .chars()
11557                    .take(120)
11558                    .collect::<String>()
11559            );
11560            assert!(
11561                body.contains("(80 saved elsewhere)"),
11562                "the heading must say how many of the total the cache cannot show, \
11563                 as a whole-list figure and not a per-page one: {}",
11564                body.split("content-count")
11565                    .nth(1)
11566                    .unwrap_or("")
11567                    .chars()
11568                    .take(120)
11569                    .collect::<String>()
11570            );
11571            assert!(
11572                !body.contains("plus 50") && !body.contains("plus 80"),
11573                "the heading is adding the uncached rows to a total that already \
11574                 includes them"
11575            );
11576        }
11577
11578        let p4 = get("/?view=starred&page=4").await;
11579        assert!(
11580            p4.contains("Page 4 of 4"),
11581            "page 4 was advertised but clamps somewhere else — the unreachable-page bug"
11582        );
11583        assert_eq!(
11584            p4.matches("<li class=\"entry").count(),
11585            30,
11586            "page 4 should hold the remaining 30 uncached records"
11587        );
11588        assert!(
11589            p4.contains("Elsewhere 79"),
11590            "the LAST saved record is unreachable — it can only be removed from here"
11591        );
11592
11593        // No uncached record appears on two pages.
11594        assert!(
11595            !p4.contains("Elsewhere 0"),
11596            "an uncached record was rendered on more than one page"
11597        );
11598        // Page 1 is all cached — and still reports the same whole-list heading,
11599        // because the parenthetical describes the LIST, not the page.
11600        let first = get("/?view=starred").await;
11601        assert!(
11602            first.contains("330 entries") && first.contains("(80 saved elsewhere)"),
11603            "the heading changed between pages; it describes the list, not the page"
11604        );
11605        assert!(
11606            !first.contains("Elsewhere "),
11607            "uncached saved records leaked onto the first page"
11608        );
11609    }
11610
11611    /// **A saved record whose article is not cached here is still shown.**
11612    ///
11613    /// The starred view is built from local `entries`, so before this a record
11614    /// starred in ANOTHER atproto reader — the portability the shared lexicon
11615    /// exists for — was simply invisible. It now renders from the PDS record,
11616    /// visually distinct, linking straight out.
11617    #[tokio::test]
11618    async fn a_saved_record_with_no_cached_entry_is_shown_as_a_link() {
11619        let did = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
11620        let sidecar =
11621            spawn_saved_sidecar("https://elsewhere.example/article", "Starred elsewhere").await;
11622        let mut state = test_state_with_sidecar(&[did], &sidecar).await;
11623        std::sync::Arc::get_mut(&mut state.config).unwrap().dev_did = Some(did.to_string());
11624
11625        let resp = router(state)
11626            .oneshot(
11627                Request::builder()
11628                    .uri("/?view=starred")
11629                    .body(Body::empty())
11630                    .unwrap(),
11631            )
11632            .await
11633            .unwrap();
11634        assert_eq!(resp.status(), StatusCode::OK);
11635        let body = String::from_utf8(
11636            axum::body::to_bytes(resp.into_body(), usize::MAX)
11637                .await
11638                .unwrap()
11639                .to_vec(),
11640        )
11641        .unwrap();
11642
11643        assert!(
11644            body.contains("Starred elsewhere"),
11645            "the saved record was not rendered at all"
11646        );
11647        assert!(
11648            body.contains("entry-uncached"),
11649            "it was not marked as uncached, so it looks like a normal entry"
11650        );
11651        assert!(
11652            body.contains("https://elsewhere.example/article"),
11653            "the row must link straight to the article"
11654        );
11655        assert!(
11656            !body.contains("/entries/0/"),
11657            "an uncached row must not offer entry actions against a nonexistent id"
11658        );
11659    }
11660
11661    /// **A PDS `createdAt` must not be able to panic the starred view.**
11662    ///
11663    /// `display_date` byte-sliced `p[..10]`. Every prior caller passed a
11664    /// timestamp the feed parser produced; the saved-record path passes a bare
11665    /// string off a PDS record, written by whatever client the reader used. A
11666    /// multi-byte value panicked the handler, and with no catch-panic layer the
11667    /// view stayed down until the record was removed — from that same view.
11668    #[test]
11669    fn a_multibyte_timestamp_does_not_panic_the_date_formatter() {
11670        for hostile in [
11671            "日本語日本語日本",
11672            "é",
11673            "",
11674            "2026",
11675            "🙂🙂🙂🙂🙂🙂🙂🙂🙂🙂🙂",
11676        ] {
11677            let out = display_date(Some(hostile));
11678            assert!(out.chars().count() <= 10, "{hostile:?} -> {out:?}");
11679        }
11680        assert_eq!(display_date(Some("2026-01-01T00:00:00Z")), "2026-01-01");
11681        assert_eq!(display_date(None), "");
11682    }
11683
11684    /// Unsaving makes a DPoP-signed PDS round-trip, which is the stated reason
11685    /// its neighbours are limited. It was added as a route and not added here.
11686    #[test]
11687    fn the_unsave_route_is_rate_limited() {
11688        use axum::http::Method;
11689        assert!(is_rate_limited_path("/saved/3abc/delete", &Method::POST));
11690        // And the neighbours still are.
11691        assert!(is_rate_limited_path("/entries/1/star", &Method::POST));
11692    }
11693
11694    /// **The probe detects a broken database — asserted through `/health`
11695    /// itself, not through a string.**
11696    ///
11697    /// A named constant did not bind the handler: it stayed free to call
11698    /// `query_scalar` with a different literal, so degrading the real probe to
11699    /// `SELECT 1` shipped green twice over. This drops the table the probe reads
11700    /// and asserts the endpoint stops saying `ok` — behaviour no substituted SQL
11701    /// can fake, because `SELECT 1` still succeeds against a wrecked schema.
11702    #[tokio::test]
11703    async fn health_reports_a_broken_database() {
11704        let state = test_state(&[]).await;
11705        // Sanity: healthy first, so the assertion below is about the damage.
11706        assert!(
11707            health_db_probe(&state.db).await.is_ok(),
11708            "the fixture was not healthy to begin with",
11709        );
11710
11711        sqlx::query("DROP TABLE feeds")
11712            .execute(&state.db)
11713            .await
11714            .unwrap();
11715
11716        assert!(
11717            health_db_probe(&state.db).await.is_err(),
11718            "the probe reported success against a database missing the table it \
11719             claims to read; `SELECT 1` would do exactly this",
11720        );
11721
11722        let resp = router(state)
11723            .oneshot(
11724                Request::builder()
11725                    .uri("/health")
11726                    .body(Body::empty())
11727                    .unwrap(),
11728            )
11729            .await
11730            .unwrap();
11731        let body = String::from_utf8(
11732            axum::body::to_bytes(resp.into_body(), usize::MAX)
11733                .await
11734                .unwrap()
11735                .to_vec(),
11736        )
11737        .unwrap();
11738        // The documented contract: the FIRST token is the state.
11739        assert!(
11740            body.starts_with("FAIL"),
11741            "/health did not report FAIL for a broken database: {body}",
11742        );
11743        assert!(
11744            !body.contains("db: ok"),
11745            "/health still called the database ok: {body}",
11746        );
11747    }
11748}