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("/standard-site", get(standard_site))
234        .route("/stats", get(stats))
235        .route("/privacy", get(privacy))
236        .route("/terms", get(terms))
237        .route("/manage", get(manage))
238        .route("/", get(index))
239        .route("/entries/{id}", get(entry_view))
240        .route("/entries/{id}/read", post(mark_read))
241        .route("/entries/{id}/star", post(toggle_star))
242        .route("/saved/{rkey}/delete", post(unsave_record))
243        .route("/read-all", post(mark_all_read))
244        .route("/subscriptions", post(add_subscription))
245        .route("/subscriptions/{rkey}/delete", post(delete_subscription))
246        .route("/subscriptions/{rkey}/rename", post(rename_subscription))
247        .route("/folders", post(create_folder))
248        .route("/folders/{rkey}/rename", post(rename_folder))
249        .route("/folders/{rkey}/delete", post(delete_folder))
250        // OPML import takes untrusted uploads: cap the body so a huge upload
251        // can't OOM (residual body-cap), on top of the streamed feed-fetch cap.
252        .route(
253            "/opml",
254            post(import_opml).layer(DefaultBodyLimit::max(OPML_BODY_LIMIT)),
255        )
256        .route("/opml/export", get(export_opml))
257        .route("/login", get(login_form).post(login_submit))
258        .route(
259            "/beta/redeem",
260            get(beta_redeem_form).post(beta_redeem_submit),
261        )
262        // The follow→invite bot's claim link: a public skeet points a new
263        // follower here with an opaque token that reserves a pre-minted code.
264        .route("/claim", get(claim))
265        // Headless bot mint endpoint (shared-secret, not OAuth). Mints a claim
266        // code + returns its token/url for the bot to post.
267        .route("/bot/claims", post(bot_mint_claim))
268        .route("/admin/invites", post(admin_mint_invites))
269        .route("/admin/metrics", get(admin_metrics))
270        .route("/oauth/client-metadata.json", get(oauth_client_metadata))
271        .route("/oauth/jwks.json", get(oauth_jwks))
272        .route("/account/delete", post(account_delete))
273        .route("/oauth/callback", get(oauth_callback))
274        .route("/logout", post(logout))
275        .nest_service("/static", ServeDir::new("static"))
276        // Browsers (and some feed clients) request /favicon.ico at the root
277        // regardless of the <link rel="icon"> tags; serve the same icon that
278        // lives under /static so the bare path stops 404-ing.
279        .route_service("/favicon.ico", ServeFile::new("static/favicon.ico"))
280        // Cache-Control (viral/CDN plan): `public, max-age=300` on the cacheable
281        // logged-out landing + static assets, `no-store` on anything that
282        // rendered a session's private view. Runs *inside* the security layers so
283        // the CSP/nosniff/frame headers are untouched.
284        .layer(middleware::from_fn(cache_control))
285        // Per-IP rate limit on the abuse-prone paths (429 over the limit). Runs
286        // as a middleware so it sees the matched path + the peer IP.
287        .layer(middleware::from_fn_with_state(rl_state, rate_limit))
288        .layer(TraceLayer::new_for_http())
289        // Baseline security headers on *every* response (F4). The CSP is the
290        // backstop that neutralises any XSS that slips past sanitization; the
291        // others harden sniffing, framing, and referrer leakage.
292        .layer(static_header_layer(
293            "content-security-policy",
294            CONTENT_SECURITY_POLICY,
295        ))
296        .layer(static_header_layer("x-content-type-options", "nosniff"))
297        .layer(static_header_layer(
298            "referrer-policy",
299            "strict-origin-when-cross-origin",
300        ))
301        .layer(static_header_layer("x-frame-options", "DENY"))
302        .with_state(state)
303}
304
305/// Body-size ceiling for the OPML import upload: **1 MiB, deliberately below
306/// axum's 2 MiB default.**
307///
308/// The value used to BE the framework default, which made the route's own
309/// `DefaultBodyLimit` layer a no-op: removing the layer changed nothing, so
310/// nothing could test it, and the ceiling this route wanted was whatever the
311/// framework happened to pick. Sized to this route instead — one outline is
312/// ~92 bytes, so 1 MiB carries ~11 000 of them against a per-DID cap
313/// (`max_subs_per_did`, default 500) the import trims to anyway. Anything
314/// larger is not a subscription list.
315///
316/// Being strictly tighter than the default is what makes the layer both real
317/// and pinnable: `opml_import_over_the_route_cap_is_refused_below_the_framework_default`
318/// uploads a payload that only this limit refuses.
319const OPML_BODY_LIMIT: usize = 1024 * 1024;
320
321/// axum's own `DefaultBodyLimit` (2 MiB as of axum 0.8), for the test that
322/// uploads a payload between the two ceilings.
323///
324/// **What matters is only that it EXCEEDS [`OPML_BODY_LIMIT`]**, not that this
325/// number is exact — axum does not export it, so it cannot be imported. The
326/// exceeding is what the test's mutation demonstrates: with the route's layer
327/// removed, a payload of this size is accepted. If axum ever lowers its
328/// default below ours, that mutation stops failing and the compile-time
329/// assertion below is the thing to revisit.
330#[cfg(test)]
331const AXUM_DEFAULT_BODY_LIMIT: usize = 2 * 1024 * 1024;
332
333/// The route's cap must stay strictly tighter than the framework's, or its
334/// layer is a no-op again. A compile error, not a test failure: this is a
335/// property of the two constants, and nothing should be able to build a binary
336/// where it is false.
337#[cfg(test)]
338const _: () = assert!(
339    OPML_BODY_LIMIT < AXUM_DEFAULT_BODY_LIMIT,
340    "OPML_BODY_LIMIT must be tighter than axum's default, or the route's layer does nothing"
341);
342
343/// A response-header layer that sets `name: value` on every response, overriding
344/// any existing header of that name. `name`/`value` must be valid static header
345/// tokens (they are, for our fixed security headers).
346fn static_header_layer(
347    name: &'static str,
348    value: &'static str,
349) -> SetResponseHeaderLayer<header::HeaderValue> {
350    SetResponseHeaderLayer::overriding(
351        header::HeaderName::from_static(name),
352        header::HeaderValue::from_static(value),
353    )
354}
355
356// ---------------------------------------------------------------------------
357// Per-IP rate limiting (token bucket, self-contained — no extra crate)
358// ---------------------------------------------------------------------------
359
360/// The abuse-prone paths the rate limiter guards (429 over the limit): the OAuth
361/// kick-off and callback, the invite redeem, logout, the mutating write
362/// endpoints, and mark-read/star/mark-all. Plain read-only navigation is
363/// intentionally *not* limited.
364///
365/// The criterion is **does this path make an outbound request**, not "does it
366/// mutate" — the two diverge, and every miss so far has been on the outbound
367/// side. This is an allowlist a new route has to be added to by hand, which is
368/// exactly why it has now been missed three times: `/saved/` (fixed), then
369/// `/oauth/callback` and `/logout`. The callback was the bad one — it is the
370/// only path here reachable with no session at all.
371///
372/// Known and deliberate gaps: `GET /`, `GET /manage` and `GET /opml/export` each
373/// make PDS calls but are ordinary authenticated navigation, and throttling them
374/// would degrade normal reading. They are bounded by needing a valid session.
375fn is_rate_limited_path(path: &str, method: &axum::http::Method) -> bool {
376    use axum::http::Method;
377    // `/claim` is a GET (a link the bot posts), but it consumes a reservation and
378    // a claim token in a public URL is grabbable, so it MUST be per-IP limited
379    // like the other abuse-prone entry points — not just `/login`.
380    // `/oauth/callback` is a GET, is UNAUTHENTICATED, and every hit performs a
381    // real outbound round-trip — a sidecar `resolve_session` or a full token
382    // exchange against a PDS. Anyone could spend one outbound request per hit.
383    // It is the only entry point here that needs no session at all.
384    if method != Method::POST
385        && !(method == Method::GET
386            && (path == "/login" || path == "/claim" || path == "/oauth/callback"))
387    {
388        return false;
389    }
390    match path {
391        // `/logout` and `/oauth/callback` are here because they make outbound
392        // calls, not because they mutate: logout revokes at the PDS (up to two
393        // round-trips) and the callback exchanges a code. The list is by
394        // *network cost*, which is what the limiter is actually for.
395        "/login" | "/claim" | "/oauth/callback" | "/logout" | "/beta/redeem" | "/subscriptions"
396        | "/opml" | "/read-all" | "/admin/invites" | "/bot/claims" | "/account/delete"
397        | "/folders" => true,
398        // Every per-record subscription/folder mutation (delete/rename) and the
399        // star/mark-read taps make a sidecar/PDS round-trip, so limit them too.
400        p => {
401            (p.starts_with("/entries/") && (p.ends_with("/read") || p.ends_with("/star")))
402                // Unsaving makes a DPoP-signed deleteRecord round-trip to the
403                // PDS, which is exactly the reason the neighbours above are
404                // limited. It was added as a new route and not added here.
405                || p.starts_with("/saved/")
406                || p.starts_with("/subscriptions/")
407                || p.starts_with("/folders/")
408        }
409    }
410}
411
412/// Middleware state for [`rate_limit`]: the shared limiter plus the trusted
413/// client-IP header (if any). Cloned into every request; both fields are cheap.
414#[derive(Clone)]
415struct RateLimitState {
416    limiter: RateLimiter,
417    /// The lowercased proxy header the operator trusts for the client IP, or
418    /// `None` to trust only the socket peer. See [`client_ip`].
419    trusted_header: Option<String>,
420}
421
422/// A tiny per-IP token-bucket rate limiter. Each IP gets [`RATE_BURST`] tokens
423/// that refill at [`RATE_REFILL_PER_SEC`]/sec; a request costs one token and is
424/// rejected (429) when the bucket is empty. Self-contained (no `tower_governor`
425/// dependency → no network fetch at build, deterministic offline CI).
426#[derive(Clone)]
427struct RateLimiter {
428    inner: std::sync::Arc<Mutex<RateLimiterState>>,
429}
430
431/// The limiter's shared state: the buckets plus when they were last swept.
432struct RateLimiterState {
433    buckets: HashMap<IpAddr, Bucket>,
434    last_sweep: Instant,
435}
436
437/// One IP's token bucket: a fractional token count + the last-refill instant.
438struct Bucket {
439    tokens: f64,
440    last: Instant,
441}
442
443/// Burst capacity per IP — how many requests can arrive back-to-back.
444const RATE_BURST: f64 = 20.0;
445/// Steady-state refill rate (tokens/sec) once the burst is spent.
446const RATE_REFILL_PER_SEC: f64 = 1.0;
447/// Evict idle buckets older than this so the map can't grow unbounded.
448const RATE_IDLE_EVICT: Duration = Duration::from_secs(3600);
449
450/// How often the idle sweep may actually run.
451///
452/// The sweep used to run on EVERY guarded request — an O(n) scan of the whole
453/// map to find entries that, by construction, can only age out on an hour
454/// boundary. `GET /login` and `GET /claim` are guarded and unauthenticated, so
455/// under any volume of distinct source IPs the server spent its single shared
456/// core re-walking a map whose contents had not changed. Once a minute is
457/// plenty: it bounds bucket lifetime at `RATE_IDLE_EVICT + RATE_SWEEP_EVERY`.
458const RATE_SWEEP_EVERY: Duration = Duration::from_secs(60);
459
460/// Most buckets kept. At roughly 100 bytes each this is ~1 MB — a bound, not a
461/// target, sized so ordinary traffic never reaches it.
462///
463/// The idle eviction above was the only bound, and it is a TIME bound, which
464/// says nothing about how many distinct IPs can arrive inside one hour.
465/// `net.rs` bounds the equivalent structure by count (`MAX_PINNED_CLIENTS`);
466/// this one did not.
467const MAX_RATE_BUCKETS: usize = 10_000;
468
469/// When the cap is hit, evict down to this fraction of it rather than removing
470/// a single entry — so the O(n) eviction happens once per `cap/8` requests
471/// instead of once per request while the map sits full.
472const RATE_EVICT_DOWN_TO: usize = MAX_RATE_BUCKETS * 7 / 8;
473
474impl RateLimiter {
475    /// A fresh, shared limiter (cloned into the middleware state).
476    fn shared() -> Self {
477        Self {
478            inner: std::sync::Arc::new(Mutex::new(RateLimiterState {
479                buckets: HashMap::new(),
480                last_sweep: Instant::now(),
481            })),
482        }
483    }
484
485    /// Charge one token for `ip`; returns `true` if allowed, `false` if the
486    /// bucket is empty (→ 429).
487    fn check(&self, ip: IpAddr) -> bool {
488        self.check_at(ip, Instant::now())
489    }
490
491    /// [`check`](Self::check) with the clock injected, so the sweep and eviction
492    /// paths below are reachable in a test without sleeping through an hour.
493    fn check_at(&self, ip: IpAddr, now: Instant) -> bool {
494        let mut state = match self.inner.lock() {
495            Ok(m) => m,
496            // A poisoned lock shouldn't take the site down — fail open.
497            Err(p) => p.into_inner(),
498        };
499
500        // Idle sweep, at most once per `RATE_SWEEP_EVERY`.
501        if now.duration_since(state.last_sweep) >= RATE_SWEEP_EVERY {
502            state
503                .buckets
504                .retain(|_, b| now.duration_since(b.last) < RATE_IDLE_EVICT);
505            state.last_sweep = now;
506        }
507
508        // Hard size bound, independent of the time bound above.
509        //
510        // Evicting LEAST-RECENTLY-USED is what makes this safe to do at all. An
511        // attacker cannot use eviction to clear their OWN throttled bucket: that
512        // bucket is by definition the most recently touched, so it is the last
513        // thing this removes. Going quiet long enough to become the oldest entry
514        // is exactly what the refill already grants for free.
515        if state.buckets.len() >= MAX_RATE_BUCKETS && !state.buckets.contains_key(&ip) {
516            let mut by_age: Vec<(IpAddr, Instant)> =
517                state.buckets.iter().map(|(k, b)| (*k, b.last)).collect();
518            by_age.sort_unstable_by_key(|(_, last)| *last);
519            for (victim, _) in by_age
520                .into_iter()
521                .take(state.buckets.len().saturating_sub(RATE_EVICT_DOWN_TO))
522            {
523                state.buckets.remove(&victim);
524            }
525            warn!(
526                buckets = state.buckets.len(),
527                "rate-limit bucket cap reached; evicted the least recently seen clients"
528            );
529        }
530
531        let bucket = state.buckets.entry(ip).or_insert(Bucket {
532            tokens: RATE_BURST,
533            last: now,
534        });
535        let elapsed = now.duration_since(bucket.last).as_secs_f64();
536        bucket.tokens = (bucket.tokens + elapsed * RATE_REFILL_PER_SEC).min(RATE_BURST);
537        bucket.last = now;
538        if bucket.tokens >= 1.0 {
539            bucket.tokens -= 1.0;
540            true
541        } else {
542            false
543        }
544    }
545}
546
547/// The **trusted** client IP for a request.
548///
549/// Security: a naive limiter that trusts the *left-most* `X-Forwarded-For` hop
550/// is fully bypassable — the left-most value is attacker-supplied (any client
551/// can send `X-Forwarded-For: <random>`), so each forged value lands in a fresh
552/// bucket and the per-IP limit never bites. We therefore derive the IP only from
553/// a source the operator controls:
554///
555/// * If `trusted_header` is configured (e.g. `Fly-Client-IP`,
556///   `CF-Connecting-IP`), we read the client IP from THAT header only — it is
557///   set by the proxy we run in front and overwrites any client-supplied copy.
558///   We take the LAST value if the header happens to be a comma list (the hop
559///   the trusted proxy appended), which is also the correct read for a
560///   right-most-`X-Forwarded-For` deployment where the operator points
561///   `trusted_header` at `x-forwarded-for`.
562/// * Otherwise we ignore all forwarding headers and use the socket peer
563///   (`ConnectInfo`) — correct for a direct bind with no proxy.
564///
565/// Returns `None` only when neither source yields a parseable IP (the limiter
566/// then fails open for that one request).
567fn client_ip(
568    headers: &HeaderMap,
569    conn: Option<&SocketAddr>,
570    trusted_header: Option<&str>,
571) -> Option<IpAddr> {
572    if let Some(name) = trusted_header {
573        if let Some(raw) = headers.get(name).and_then(|v| v.to_str().ok()) {
574            // Right-most hop is the one the trusted proxy appended; earlier
575            // entries may be client-forged, so never trust the left-most.
576            if let Some(last) = raw.split(',').next_back() {
577                if let Ok(ip) = last.trim().parse::<IpAddr>() {
578                    return Some(ip);
579                }
580            }
581        }
582        // Trusted header absent/unparseable → fall through to the socket peer.
583    }
584    conn.map(|s| s.ip())
585}
586
587/// Rate-limit middleware: 429 on the abuse-prone paths once an IP's bucket is
588/// empty; every other request (and every non-guarded path) passes through. The
589/// peer `SocketAddr` is read from the request extension `ConnectInfo` sets (via
590/// `into_make_service_with_connect_info`), preferring `X-Forwarded-For`.
591async fn rate_limit(
592    State(rl): State<RateLimitState>,
593    req: axum::extract::Request,
594    next: Next,
595) -> Response {
596    let path = req.uri().path().to_string();
597    let method = req.method().clone();
598    if is_rate_limited_path(&path, &method) {
599        let conn = req
600            .extensions()
601            .get::<ConnectInfo<SocketAddr>>()
602            .map(|c| c.0);
603        let ip = client_ip(req.headers(), conn.as_ref(), rl.trusted_header.as_deref());
604        // Deliberately fail OPEN when no client IP is derivable (no trusted
605        // header / no socket peer): there is no per-IP key to enforce, and a
606        // blanket 429 would self-DoS every guarded path (incl. /login). This is
607        // safe precisely because we never key on an attacker-forged XFF — see
608        // `rate_limit_ignores_spoofed_xff_rotation`.
609        if let Some(ip) = ip {
610            if !rl.limiter.check(ip) {
611                warn!(%ip, %path, "rate limit exceeded");
612                return (
613                    StatusCode::TOO_MANY_REQUESTS,
614                    [(header::RETRY_AFTER, "1")],
615                    "rate limit exceeded\n",
616                )
617                    .into_response();
618            }
619        }
620    }
621    next.run(req).await
622}
623
624// ---------------------------------------------------------------------------
625// Cache-Control (viral / CDN vs. private authenticated views)
626// ---------------------------------------------------------------------------
627
628/// Cache-Control middleware. Emits `public, max-age=300` on the cacheable
629/// logged-out surfaces (the `/login` landing without a handle, `/about`,
630/// `/standard-site`, `/privacy`, `/terms`, and the `/static/*` assets) and `no-store` on the
631/// authenticated app pages, so a CDN /
632/// browser can hold the viral landing while never caching a signed-in user's
633/// private view. Never overrides a handler that already set Cache-Control.
634async fn cache_control(req: axum::extract::Request, next: Next) -> Response {
635    let path = req.uri().path().to_string();
636    // The logged-out landing is only cacheable when it's the bare form — a
637    // `?handle=` GET kicks off OAuth (a redirect), which must not be cached.
638    let is_login_landing = path == "/login"
639        && req.method() == axum::http::Method::GET
640        && !req.uri().query().unwrap_or("").contains("handle=");
641    let public = is_login_landing
642        || path == "/about"
643        || path == "/standard-site"
644        || path == "/privacy"
645        || path == "/terms"
646        || path.starts_with("/static/");
647
648    let mut resp = next.run(req).await;
649    if resp.headers().contains_key(header::CACHE_CONTROL) {
650        return resp;
651    }
652    let value = if public {
653        "public, max-age=300"
654    } else {
655        "no-store"
656    };
657    if let Ok(hv) = header::HeaderValue::from_str(value) {
658        resp.headers_mut().insert(header::CACHE_CONTROL, hv);
659    }
660    resp
661}
662
663// ---------------------------------------------------------------------------
664// Health
665// ---------------------------------------------------------------------------
666
667/// Run `/health`'s database probe. **The single path, so a test cannot assert
668/// on a string the handler is free to ignore** — a named constant alone was not
669/// enough: the test read the constant while the handler passed `query_scalar`
670/// whatever it liked, so degrading the real call to `SELECT 1` shipped green.
671async fn health_db_probe(pool: &store::Pool) -> Result<Option<i64>, sqlx::Error> {
672    sqlx::query_scalar::<_, i64>(HEALTH_DB_PROBE_SQL)
673        .fetch_optional(pool)
674        .await
675}
676
677/// The statement `/health` uses to prove the database is readable.
678///
679/// **A named constant so the test can assert on the query that actually runs.**
680/// `the_health_probe_opens_a_real_table` used to `EXPLAIN` a hand-typed copy of
681/// this string, so degrading the real probe to `SELECT 1` — which opens no page
682/// and therefore cannot detect a broken database — left the suite green.
683const HEALTH_DB_PROBE_SQL: &str = "SELECT 1 FROM feeds LIMIT 1";
684
685/// How long `/health` will wait for its database ping before calling it broken.
686///
687/// Under `fly.toml`'s 3 s check timeout, so a hung pool produces a 503 this
688/// handler chose rather than a timeout Fly inferred — the difference between a
689/// log line that says why and one that says nothing.
690const HEALTH_DB_TIMEOUT: Duration = Duration::from_secs(2);
691
692/// Floor for the poll-heartbeat staleness threshold. **Reported, never fatal** —
693/// see the handler for why.
694///
695/// The threshold itself is derived from the configured tick
696/// ([`health_tick_stale_secs`]): hardcoding 15 minutes meant an operator who
697/// raised `FEATHERREADER_POLL_TICK_SECS` above 900 got a permanent `poller:
698/// stale` in the body the deployment docs now tell them to alert on.
699const HEALTH_TICK_STALE_FLOOR_SECS: i64 = 15 * 60;
700
701/// How long without a completed tick before the poller reads as stale: several
702/// tick intervals, floored, so a normally-paced loop never trips it and a
703/// genuinely wedged one always does.
704fn health_tick_stale_secs(tick: Duration) -> i64 {
705    let tick = i64::try_from(tick.as_secs()).unwrap_or(i64::MAX);
706    tick.saturating_mul(5).max(HEALTH_TICK_STALE_FLOOR_SECS)
707}
708
709/// The poll tick this instance is configured for. Read from the same env var
710/// `scheduler.rs` reads, because the scheduler lives in the binary crate and the
711/// handler cannot see its constants.
712fn configured_poll_tick() -> Duration {
713    std::env::var("FEATHERREADER_POLL_TICK_SECS")
714        .ok()
715        .and_then(|v| v.trim().parse::<u64>().ok())
716        .filter(|s| *s > 0)
717        .map_or(DEFAULT_POLL_TICK_SECS, Duration::from_secs)
718}
719
720/// Mirrors `scheduler::DEFAULT_POLL_TICK`, which lives in the BINARY crate and
721/// so cannot be imported here. Duplicated deliberately and named, rather than
722/// left as a bare `60` inside the parse chain, so the drift is at least visible
723/// if the scheduler's value ever moves.
724const DEFAULT_POLL_TICK_SECS: Duration = Duration::from_secs(60);
725
726/// Grace period after boot before a poller that has never ticked is called
727/// `stale` rather than `not-yet-ticked`.
728///
729/// Without this the two are indistinguishable forever, which matters precisely
730/// in the case the startup delays were added for: in a crash loop with 30 s+ boot
731/// cycles the poller never reaches its first tick, so `/health` reported the
732/// benign `not-yet-ticked` on every single probe and the heartbeat could not
733/// detect the failure mode it exists for. `run_poller` returning early — a failed
734/// HTTP client build — has the same shape and was equally invisible.
735///
736/// Sized off the poller's own startup delay plus its tick, with slack.
737const HEALTH_FIRST_TICK_GRACE_SECS: i64 = 5 * 60;
738
739/// `GET /health` — does this process still work, and what are its loops doing?
740///
741/// This used to return a constant string, touching no database, no pool and no
742/// scheduler state — while being the ONLY automated signal in `fly.toml`, whose
743/// sole other failure detector is a child process exiting. It proved the HTTP
744/// listener was up and nothing else.
745///
746/// **What can fail the check: the database, and only the database.** A process
747/// that cannot reach its store serves nothing, so a restart is the right
748/// response and this returns 503. The probe is a read (`SELECT 1`), which in WAL
749/// mode is not blocked by any writer — so the retention sweep, the poller and a
750/// login burst cannot make this flap. That property is the reason it is a read
751/// and not, say, a write canary.
752///
753/// **What is reported but never fails the check: everything else.** A stale poll
754/// heartbeat, a watermark pause, a missing OAuth runtime — all real problems,
755/// and none of them a reason to stop serving.
756///
757/// That last clause is the whole justification, and it is NOT the one this
758/// comment used to give. It said "Fly restarts on a failed check", which is
759/// false — verified against Fly's own docs, which state it three times: *"your
760/// Machines won't automatically restart or stop due to failing their health
761/// checks"*. A failing `[[http_service.checks]]` check makes Fly Proxy stop
762/// ROUTING to the Machine. Nothing restarts it. That capability existed on Apps
763/// V1 (`restart_limit`) and has no successor on Machines.
764///
765/// The corrected model makes the conclusion stronger, not weaker. With one
766/// Machine there is no healthy peer to shift traffic to, so a 503 here is not a
767/// failover — it is a total outage that lasts exactly as long as the condition,
768/// and it also fails a `fly deploy` (rolling strategy, no auto-rollback). So the
769/// question the status code answers is not "would a restart fix this" but **"can
770/// this process still serve a useful request at all"**. A stale poller can. A
771/// database it cannot read cannot.
772///
773/// Re-registration is automatic: the proxy keeps probing and routes again the
774/// moment the check passes. That is what makes a 503 recoverable without
775/// intervention — not a restart, which never comes.
776///
777/// The body is machine facts only — no user counts, no DIDs, no feed URLs — so
778/// it is publishable on the same terms as `/stats`. It is also the non-session
779/// diagnostic for an OAuth outage: when nobody can log in, `/admin/metrics`
780/// (which needs a live admin session) is exactly as unreachable as the thing it
781/// would diagnose, while this is reachable with `curl`.
782async fn health(State(state): State<AppState>) -> Response {
783    let now = chrono::Utc::now().timestamp();
784    let rh = &state.runtime_health;
785
786    use crate::runtime_health::DbProbe;
787    let db = match rh.begin_db_probe() {
788        // A probe is already in flight; report its predecessor rather than
789        // starting a second one. See `RuntimeHealth::begin_db_probe`.
790        Err(borrowed) => borrowed,
791        Ok(probe) => {
792            // **Spawned, so the probe cannot be cancelled by the caller.**
793            //
794            // Axum drops the handler future when a client disconnects. With the
795            // probe inline, that dropped it mid-flight and released the claim
796            // WITHOUT recording a verdict — which let an unauthenticated caller
797            // manufacture the no-verdict state on demand and freeze what every
798            // other caller, Fly's check included, reads. Running it detached
799            // means the verdict is always recorded and the claim is always
800            // released after it.
801            let pool = state.db.clone();
802            let task = tokio::spawn(async move {
803                // **`SELECT 1` was not a database probe.** It compiles to
804                // `Init/Integer/ResultRow/Halt` — there is no `OpenRead`, so it
805                // never touches a b-tree, never reads a page, and never consults
806                // the file. Against a corrupted database it returns success
807                // while every real query returns SQLITE_CORRUPT. Reading one row
808                // from a real table costs the same and actually proves what the
809                // check claims. `LIMIT 1` keeps it to a single page; an empty
810                // table still opens the b-tree root, which is the part that
811                // matters.
812                let verdict =
813                    match tokio::time::timeout(HEALTH_DB_TIMEOUT, health_db_probe(&pool)).await {
814                        Ok(Ok(_)) => DbProbe::Ok,
815                        // Coarse, not the raw error. An unauthenticated caller
816                        // learning exactly which failure it hit is an
817                        // attack-progress oracle; the detail belongs in the log,
818                        // which gets it here.
819                        Ok(Err(err)) => {
820                            warn!(%err, "health: database probe failed");
821                            DbProbe::Failed("unavailable".to_string())
822                        }
823                        Err(_) => {
824                            warn!(
825                                timeout_s = HEALTH_DB_TIMEOUT.as_secs(),
826                                "health: database probe timed out (pool exhausted?)"
827                            );
828                            DbProbe::Failed("timeout".to_string())
829                        }
830                    };
831                probe.record(verdict.clone());
832                verdict
833            });
834            // A panicking task drops the guard, which releases the claim without
835            // a verdict — the only remaining path to that state, and not one a
836            // caller can drive.
837            task.await.unwrap_or(DbProbe::Unknown)
838        }
839    };
840
841    let uptime = rh.uptime_secs(now);
842    let poller = if !rh.schedulers_enabled() {
843        // Not a fault. Dev runs and the seam tests disable the loops on purpose,
844        // and reporting that as "stale" would be a false alarm on every one.
845        "disabled".to_string()
846    } else {
847        match rh.secs_since_poll_tick(now) {
848            // "Never ticked" is benign right after boot and alarming well after
849            // it — so it is read against UPTIME, not left permanently benign.
850            None => match uptime {
851                Some(up) if up > HEALTH_FIRST_TICK_GRACE_SECS => {
852                    format!("stale never-ticked {up}s")
853                }
854                _ => "not-yet-ticked".to_string(),
855            },
856            Some(secs) if secs > health_tick_stale_secs(configured_poll_tick()) => {
857                format!("stale {secs}s")
858            }
859            Some(secs) => format!("ok {secs}s"),
860        }
861    };
862
863    // **Only a MEASURED failure fails the check.**
864    //
865    // `Unknown` means no probe has completed — a concurrent request arrived
866    // before the first one finished, or a previous owner was cancelled before
867    // recording. It is reported and returns 200, because an unmeasured database
868    // is not evidence of a broken one, and this endpoint is reachable by
869    // unauthenticated callers who can manufacture that state. Treating it as a
870    // failure handed them a lever on the only signal the platform acts on.
871    let mut body = String::new();
872    let status = match &db {
873        DbProbe::Ok => {
874            body.push_str(&format!("ok featherreader/{VERSION}\n"));
875            body.push_str("db: ok\n");
876            StatusCode::OK
877        }
878        // **Not `ok`.** The first token is the state, and this one is neither
879        // healthy nor failed. It used to print a line byte-identical to the
880        // healthy branch, which mattered because `fly.toml` tells operators to
881        // alert on the BODY for everything the status code deliberately ignores
882        // — so a monitor keying on `^ok` read green in exactly the state this
883        // enum exists to make visible.
884        DbProbe::Unknown => {
885            body.push_str(&format!("unknown featherreader/{VERSION}\n"));
886            body.push_str("db: unknown (no probe has completed yet)\n");
887            StatusCode::OK
888        }
889        DbProbe::Failed(why) => {
890            body.push_str(&format!("FAIL featherreader/{VERSION}\n"));
891            body.push_str(&format!("db: {why}\n"));
892            StatusCode::SERVICE_UNAVAILABLE
893        }
894    };
895    // Uptime answers the first question anyone asks about a container under a
896    // supervisor that tears the machine down whenever a child exits: is this
897    // thing restarting? Nothing else on any surface could tell you.
898    body.push_str(&format!(
899        "uptime: {}\n",
900        match uptime {
901            Some(secs) => format!("{secs}s"),
902            None => "unknown".to_string(),
903        }
904    ));
905    body.push_str(&format!("poller: {poller}\n"));
906    body.push_str(&format!(
907        "polling-paused: {}\n",
908        if rh.watermark_paused() { "yes" } else { "no" }
909    ));
910    // Deliberately NOT the measured database size. `/health` is the one path
911    // exempted from the Caddy origin lock, so it answers direct hits to the Fly
912    // IP that never passed Cloudflare — which caps what belongs here at the
913    // class of facts `/stats` already publishes to anyone. "Polling is paused"
914    // is that; the exact byte count is a precise internal number that adds
915    // nothing an operator cannot get from `/stats` or the logs.
916    body.push_str(&format!(
917        "backend: {}\n",
918        state.config.repo_backend.as_str()
919    ));
920    body.push_str(&format!(
921        "oauth-runtime: {}\n",
922        if state.oauth.is_some() {
923            "built"
924        } else {
925            "absent"
926        }
927    ));
928
929    // Never cached: a stale health response is worse than none, and Cloudflare
930    // sits in front of this.
931    let mut resp = (status, body).into_response();
932    if let Ok(hv) = header::HeaderValue::from_str("no-store") {
933        resp.headers_mut().insert(header::CACHE_CONTROL, hv);
934    }
935    resp
936}
937
938/// `GET /about` — the public-experiment page: the full disclaimer (experimental,
939/// no SLA, may pause anytime), the OSS / self-host pitch, and the tip link.
940/// Readable whether or not a session exists.
941///
942/// Optionally carries one quiet line about network adoption
943/// (`design/NETWORK-SPEC.md` §4.4). With `FEATHERREADER_SHOW_ADOPTION` off — the
944/// default — the handler issues **zero** queries and the page is byte-identical
945/// to what it was before the probe existed.
946async fn about(State(state): State<AppState>) -> Response {
947    let adoption = if state.config.show_adoption {
948        adoption_line(&state).await
949    } else {
950        None
951    };
952    render(&AboutTemplate {
953        card: Card::public(
954            &state.config,
955            "/about",
956            "About — FeatherReader",
957            "What FeatherReader is and isn't: an open-source, atproto-native reader for \
958             RSS feeds and standard.site publications, run as an experiment, free to \
959             self-host under the AGPL.",
960        ),
961        version: VERSION,
962        repo_url: REPO_URL,
963        kofi_url: KOFI_URL,
964        adoption,
965        standard_site: state.config.standard_site,
966    })
967}
968
969/// `GET /standard-site` — the public feature page for standard.site
970/// publications: what a publication is, what FeatherReader shows from one, how
971/// to subscribe, the limits, and the latest releases. Readable whether or not
972/// a session exists, like `/about`. Every how-to-subscribe line is conditional
973/// on `Config::standard_site`, as on the other public pages.
974async fn standard_site(State(state): State<AppState>) -> Response {
975    render(&StandardSiteTemplate {
976        card: Card::public(
977            &state.config,
978            "/standard-site",
979            "standard.site — FeatherReader",
980            "Read standard.site publications beside your RSS feeds: articles \
981             published as atproto records, followed with the same portable \
982             subscription record.",
983        ),
984        version: VERSION,
985        repo_url: REPO_URL,
986        kofi_url: KOFI_URL,
987        standard_site: state.config.standard_site,
988        releases: RELEASES,
989    })
990}
991
992/// `POST /saved/:rkey/delete` — remove a saved record that has no local entry.
993///
994/// The normal star toggle is keyed on an entry id, which a PDS-only saved row
995/// does not have. This deletes the record straight from the repo by its rkey,
996/// and then clears any LOCAL star for the same article.
997///
998/// That second step is not belt-and-braces. "Has no local entry" is how the
999/// starred view classifies a record, and it decides that through `sub_ref` — so
1000/// an article that really is cached, and really is starred, lands here whenever
1001/// the reader has unsubscribed from its feed. Deleting only the record left
1002/// `entry_state.starred = 1` behind: invisible, because the starred list is
1003/// `sub_ref`-scoped too, until a resubscribe brought the star back with nothing
1004/// in the PDS backing it. A reader who clicks "remove" gets it removed from both
1005/// places it lives.
1006async fn unsave_record(
1007    State(state): State<AppState>,
1008    headers: HeaderMap,
1009    Path(rkey): Path<String>,
1010) -> Response {
1011    let Some(did) = current_did(&state, &headers).await else {
1012        return (StatusCode::UNAUTHORIZED, "sign in first\n").into_response();
1013    };
1014
1015    // Read the record's identity BEFORE deleting it — afterwards there is
1016    // nothing left to learn it from. Best-effort: a failure here must not block
1017    // the deletion the reader actually asked for, so it degrades to the old
1018    // behaviour (record gone, local star possibly stale) and says so.
1019    let identity = match state.repo().list_saved(&did).await {
1020        Ok(records) => records
1021            .into_iter()
1022            .find(|(k, _)| *k == rkey)
1023            .map(|(_, rec)| (rec.url, rec.entry_id)),
1024        Err(err) => {
1025            warn!(%err, %did, %rkey, "could not read the saved record before deleting it; \
1026                                      a local star for the same article may survive");
1027            None
1028        }
1029    };
1030
1031    match state.repo().remove_saved(&did, &rkey).await {
1032        Ok(()) => info!(%did, %rkey, "removed a saved record with no cached entry"),
1033        Err(err) => {
1034            warn!(%err, %did, %rkey, "could not remove the saved record");
1035            return (StatusCode::BAD_GATEWAY, "could not remove that item\n").into_response();
1036        }
1037    }
1038
1039    // Deliberately AFTER the delete: the PDS is the source of truth for what was
1040    // saved, so clearing the local star before knowing the record is gone would
1041    // be the desync in the other direction.
1042    if let Some((url, guid)) = identity {
1043        match store::clear_star_by_identity(&state.db, &did, Some(&url), guid.as_deref()).await {
1044            Ok(0) => {}
1045            Ok(n) => {
1046                info!(%did, %rkey, cleared = n, "cleared the local star for an unsaved record")
1047            }
1048            Err(err) => warn!(%err, %did, %rkey, "could not clear the local star after unsaving"),
1049        }
1050    }
1051    // htmx swaps the row out; a plain form post goes back to the starred list.
1052    if is_htmx(&headers) {
1053        return (StatusCode::OK, "").into_response();
1054    }
1055    Redirect::to("/?view=starred").into_response()
1056}
1057
1058/// What the poller is doing, as one word for `/stats`.
1059///
1060/// **Parity with `/health` is the point.** `polling_paused` alone reported
1061/// "running" for three different states including the two where nothing polls,
1062/// on the page added to answer exactly that. The first attempt at fixing it
1063/// added `off` and `starting` and claimed parity — but left out `stale`, so a
1064/// poll loop that ticked once at boot and then WEDGED still read as running.
1065/// That is the wedged-loop case `/health`'s heartbeat exists for, and the
1066/// original finding's exact shape surviving its own fix.
1067///
1068/// Shares the staleness threshold with `/health` rather than picking its own, so
1069/// the two pages cannot disagree about what "stale" means.
1070fn fetching_state(rh: &crate::runtime_health::RuntimeHealth, now_unix: i64) -> &'static str {
1071    if !rh.schedulers_enabled() {
1072        return "off";
1073    }
1074    // Checked before the pause: a wedged poller cannot clear a pause either, so
1075    // reporting "paused" would name the symptom and hide the cause.
1076    match rh.secs_since_poll_tick(now_unix) {
1077        None => {
1078            // Never ticked. Benign at boot, a dead loop long after — read
1079            // against uptime, exactly as `/health` does.
1080            match rh.uptime_secs(now_unix) {
1081                Some(up) if up > HEALTH_FIRST_TICK_GRACE_SECS => "stale",
1082                _ => "starting",
1083            }
1084        }
1085        Some(secs) if secs > health_tick_stale_secs(configured_poll_tick()) => "stale",
1086        _ if rh.watermark_paused() => "paused",
1087        _ => "running",
1088    }
1089}
1090
1091/// `GET /stats` — public poll health.
1092async fn stats(State(state): State<AppState>) -> Response {
1093    let now = chrono::Utc::now();
1094    let health = match store::poll_health(
1095        &state.db,
1096        &now.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
1097        &(now - chrono::Duration::hours(1)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
1098    )
1099    .await
1100    {
1101        Ok(health) => health,
1102        Err(err) => {
1103            warn!(%err, "could not compute poll health");
1104            return (StatusCode::INTERNAL_SERVER_ERROR, "stats unavailable\n").into_response();
1105        }
1106    };
1107
1108    // Percentage of a zero-feed instance is 100, not a divide-by-zero: a fresh
1109    // instance is not behind on anything.
1110    let polled_pct = if health.feeds_tracked == 0 {
1111        100
1112    } else {
1113        health.polled_last_hour * 100 / health.feeds_tracked
1114    };
1115
1116    render(&StatsTemplate {
1117        card: Card::public(
1118            &state.config,
1119            "/stats",
1120            "Stats — FeatherReader",
1121            "Is this instance's poller keeping up? Aggregate feed-polling health — \
1122             counts only; no feed and no reader is named.",
1123        ),
1124        version: VERSION,
1125        repo_url: REPO_URL,
1126        kofi_url: KOFI_URL,
1127        feeds_tracked: health.feeds_tracked,
1128        polled_last_hour: health.polled_last_hour,
1129        polled_pct,
1130        overdue: health.overdue,
1131        last_poll: humanise_ago(health.last_poll_secs_ago),
1132        oldest_poll: if health.never_polled > 0 {
1133            "never".to_string()
1134        } else {
1135            humanise_ago(health.oldest_poll_secs_ago)
1136        },
1137        never_polled: health.never_polled,
1138        poll_interval_mins: state.config.poll_interval.as_secs() as i64 / 60,
1139        // **The two states that actually stop feeds updating.**
1140        //
1141        // Neither was visible anywhere. `overdue` and `polled_last_hour` move in
1142        // both and distinguish neither — and `overdue` moves the WRONG WAY for
1143        // backoff, since backoff is applied by pushing `next_poll` forward, so a
1144        // feed failing every fetch drops out of the backlog and makes the page
1145        // read healthier. Both of these are machine facts with no per-feed
1146        // detail, so they sit inside the page's stated contract.
1147        in_backoff: health.in_backoff,
1148        badly_broken: health.badly_broken,
1149        failure_kinds: health.failure_kinds,
1150        fetching: fetching_state(&state.runtime_health, now.timestamp()),
1151    })
1152}
1153
1154/// "3h 11m ago", or "never" when there has been no poll at all.
1155///
1156/// `None` must not render as `0` — on a fresh instance that would read as
1157/// "polled just now", which is the opposite of the truth.
1158fn humanise_ago(secs: Option<i64>) -> String {
1159    let Some(secs) = secs else {
1160        return "never".to_string();
1161    };
1162    match secs {
1163        s if s < 60 => format!("{s}s ago"),
1164        s if s < 3600 => format!("{}m ago", s / 60),
1165        s => format!("{}h {}m ago", s / 3600, (s % 3600) / 60),
1166    }
1167}
1168
1169/// The `/about` adoption line's data, or `None` (no successful probe yet, an
1170/// observation of zero, or a store failure).
1171///
1172/// A read failure degrades to `None` plus a `warn!` rather than propagating: the
1173/// probe is never allowed to affect the reader, and that rule applies at the
1174/// display end too — a locked or corrupt DB costs the About page one log line,
1175/// not a 500.
1176async fn adoption_line(state: &AppState) -> Option<AdoptionLine> {
1177    match store::latest_network_stat(&state.db, store::ADOPTION_STAT_KEY).await {
1178        // A legitimate zero renders nothing rather than a sad "0 accounts".
1179        Ok(Some(stat)) if stat.value > 0 => Some(AdoptionLine {
1180            repos: stat.value,
1181            truncated: stat.truncated,
1182            observed_on: stat
1183                .observed_at
1184                .split('T')
1185                .next()
1186                .unwrap_or_default()
1187                .to_string(),
1188        }),
1189        Ok(_) => None,
1190        Err(err) => {
1191            warn!(%err, "about: adoption stat read failed; omitting the line");
1192            None
1193        }
1194    }
1195}
1196
1197/// `GET /privacy` — the plain-language privacy page: no account/tracking, data
1198/// lives in the user's PDS, what the server caches, and the session-token
1199/// handling. A static render; readable whether or not a session exists.
1200async fn privacy(State(state): State<AppState>) -> Response {
1201    render(&PrivacyTemplate {
1202        card: Card::public(
1203            &state.config,
1204            "/privacy",
1205            "Privacy — FeatherReader",
1206            "No account and no tracking: your subscriptions and reading state live in \
1207             your own PDS. What this server caches, for how long, and how the session \
1208             token is handled.",
1209        ),
1210        version: VERSION,
1211        repo_url: REPO_URL,
1212        kofi_url: KOFI_URL,
1213    })
1214}
1215
1216/// `GET /terms` — the terms of use: the experimental / as-is disclaimer,
1217/// acceptable use, the AGPL/self-host note, and the liability limitation. A
1218/// static render; readable whether or not a session exists.
1219async fn terms(State(state): State<AppState>) -> Response {
1220    render(&TermsTemplate {
1221        card: Card::public(
1222            &state.config,
1223            "/terms",
1224            "Terms — FeatherReader",
1225            "The terms of use: an experimental service offered as-is with no warranty, \
1226             what acceptable use means here, and the AGPL self-host note.",
1227        ),
1228        version: VERSION,
1229        repo_url: REPO_URL,
1230        kofi_url: KOFI_URL,
1231    })
1232}
1233
1234// ---------------------------------------------------------------------------
1235// View models
1236// ---------------------------------------------------------------------------
1237
1238/// A feed as shown in the sidebar (title + its unread count + a stable scope key
1239/// and the PDS subscription rkey for management actions).
1240struct FeedView {
1241    /// PDS subscription rkey — addresses the record for rename/unsubscribe.
1242    rkey: String,
1243    /// Canonical feed URL — the sidebar filter key (`?feed=<url>`).
1244    url: String,
1245    title: String,
1246    unread: i64,
1247    /// Whether this feed is the currently-selected scope.
1248    selected: bool,
1249    /// The feed's current folder `at://` URI (from its subscription record), or
1250    /// `None` if un-foldered. Drives the pre-selected `<option>` in the manage
1251    /// rename row so an untouched folder dropdown does not silently un-folder the
1252    /// feed on save.
1253    folder: Option<String>,
1254}
1255
1256/// A folder grouping in the sidebar, sourced from the PDS `folder` records.
1257struct FolderView {
1258    /// PDS folder rkey — addresses the record for rename/delete.
1259    rkey: String,
1260    /// The folder's `at://` URI — the sidebar filter key (`?folder=<uri>`).
1261    uri: String,
1262    name: String,
1263    feeds: Vec<FeedView>,
1264    /// Whether this folder is the currently-selected scope.
1265    selected: bool,
1266}
1267
1268/// One entry as shown in the article list / after an htmx swap.
1269struct EntryRow {
1270    id: i64,
1271    title: String,
1272    feed_title: String,
1273    published: String,
1274    read: bool,
1275    starred: bool,
1276    /// The reader link href, already carrying the scope/view query so opening an
1277    /// entry and paging back stays within the list it came from.
1278    link: SafeLink,
1279    /// Whether the article itself is in this instance's cache.
1280    ///
1281    /// `false` for a saved record that exists in the reader's PDS but whose
1282    /// entry was never cached here — starred in another atproto reader, or
1283    /// starred here and since evicted. There is no local row, so the row has no
1284    /// usable `id`: it links straight out to the article and carries no
1285    /// mark-read control, because there is nothing local to mark.
1286    cached: bool,
1287    /// The PDS record key, for un-saving a row that has no local entry.
1288    rkey: String,
1289}
1290
1291/// A folder as an option in the "move feed to folder" select.
1292struct FolderOption {
1293    uri: String,
1294    name: String,
1295}
1296
1297/// The shared navigation "rail" model: the same DOM element is the
1298/// mobile drawer and the desktop sidebar, so every chrome page (list / reader /
1299/// manage) renders it from this one struct. Feed management lives on `/manage`,
1300/// not here — the rail is navigation only.
1301struct Nav {
1302    /// `@handle` for the identity chip (falls back to the DID's tail).
1303    handle: String,
1304    /// Two-letter avatar initials for the identity chip.
1305    avatar: String,
1306    /// The active filter: `"unread" | "all" | "starred"` (drives `aria-current`).
1307    view: String,
1308    /// The scope query suffix (`feed=…` / `folder=…`) carried onto filter links,
1309    /// empty for the unscoped "everything" views.
1310    scope_qs: String,
1311    /// Folders (each with its feeds) then un-foldered feeds, for the rail lists.
1312    /// Per-feed `selected` flags drive the rail's feed `aria-current`.
1313    folders: Vec<FolderView>,
1314    loose_feeds: Vec<FeedView>,
1315    /// Whether the "Manage feeds" rail tool is the current page.
1316    manage_active: bool,
1317}
1318
1319/// The subscribe input's `pattern` when standard.site is on, and the input is
1320/// `type="text"` (see `templates/manage.html`). It keeps the browser asking for
1321/// a scheme, as `type="url"` did, while admitting `at://`. Matched in any case,
1322/// because the handler canonicalises the scheme. Browsers compile `pattern`
1323/// with the `v` flag and anchor it at both ends. Unlike `type="url"`, a text
1324/// input does not strip surrounding whitespace before checking, so the pattern
1325/// allows it: a URL pasted with a leading space is common, and the handler
1326/// trims it.
1327pub(crate) const FEED_URL_PATTERN: &str = "\\s*(?:[Hh][Tt][Tt][Pp][Ss]?|[Aa][Tt])://.+";
1328
1329// ---------------------------------------------------------------------------
1330// Link cards (Open Graph / Twitter / Bluesky)
1331// ---------------------------------------------------------------------------
1332
1333/// The site's own title: the landing page's, and the one every private view
1334/// shows instead of its own.
1335const SITE_TITLE: &str = "FeatherReader — read, quietly";
1336
1337/// The site's one-paragraph description: the landing page's, and the one every
1338/// private view shows instead of its own.
1339const SITE_DESCRIPTION: &str = "A minimalist, atproto-native reader for RSS feeds and \
1340standard.site publications. Your subscriptions live in your own PDS — no signup, no \
1341password, no tracking.";
1342
1343/// Where the share image is served, relative to the public origin. The file is
1344/// `static/social-card.png`, rendered from `static/social-card.svg` by
1345/// `scripts/social-card.sh`; `base.html` advertises its dimensions, and a test
1346/// checks the PNG's own header agrees.
1347const SHARE_IMAGE_PATH: &str = "/static/social-card.png";
1348
1349/// What a link to a page unfurls as when it is posted — on Bluesky, in a chat,
1350/// anywhere that reads Open Graph tags. `base.html` renders it into `<head>`.
1351///
1352/// Measured before this existed: Bluesky's card service
1353/// (`cardyb.bsky.app/v1/extract?url=https://feather-reader.com/`) returned
1354/// `{"title":"FeatherReader — read, quietly","description":"","image":""}`,
1355/// because `<title>` was the only tag it could find. Card fetchers read the
1356/// initial HTML server-side, run no JS, and resolve nothing relative, so every
1357/// URL here is absolute on [`Config::public_url`] — `https://feather-reader.com`
1358/// in production, whatever `FEATHERREADER_PUBLIC_URL` says elsewhere.
1359#[derive(Debug, Clone)]
1360pub(crate) struct Card {
1361    /// `og:title`. On a public page, the same text as its `<title>`.
1362    pub title: String,
1363    /// `og:description` and `<meta name="description">`: one or two plain
1364    /// sentences about THIS page, not the site.
1365    pub description: String,
1366    /// `og:url` and `<link rel="canonical">`: absolute, on the public origin.
1367    pub url: String,
1368    /// `og:image`: absolute, on the public origin.
1369    pub image: String,
1370    /// Set on a page that renders a session's private view. The card is then
1371    /// the site's generic one — nothing from the view reaches `<head>` — and
1372    /// the page is `noindex`.
1373    pub private: bool,
1374}
1375
1376impl Card {
1377    /// The card of the public page at `path` (leading slash) on this instance.
1378    fn public(
1379        config: &Config,
1380        path: &str,
1381        title: impl Into<String>,
1382        description: impl Into<String>,
1383    ) -> Self {
1384        let origin = config.public_url.trim_end_matches('/');
1385        Card {
1386            title: title.into(),
1387            description: description.into(),
1388            url: format!("{origin}{path}"),
1389            image: format!("{origin}{SHARE_IMAGE_PATH}"),
1390            private: false,
1391        }
1392    }
1393
1394    /// The landing page's card: the site's own title and description.
1395    fn site(config: &Config) -> Self {
1396        Card::public(config, "/", SITE_TITLE, SITE_DESCRIPTION)
1397    }
1398
1399    /// The card of a page that renders a session's private view: the site's
1400    /// generic card pointing at the front door, plus `noindex`. The view's
1401    /// heading, feed names and handle stay out of `<head>`.
1402    fn private(config: &Config) -> Self {
1403        Card {
1404            private: true,
1405            ..Card::site(config)
1406        }
1407    }
1408}
1409
1410/// The reader index (`GET /`).
1411#[derive(Template)]
1412#[template(path = "index.html")]
1413struct IndexTemplate {
1414    /// The link card. A private view: the site's generic card, `noindex`.
1415    card: Card,
1416    version: &'static str,
1417    repo_url: &'static str,
1418    kofi_url: &'static str,
1419    flash: String,
1420    /// Shown as `role="alert"` when the subscription list is the cached one
1421    /// because the PDS listing failed; empty otherwise.
1422    alert: String,
1423    /// The shared rail (drawer + desktop sidebar) navigation model.
1424    nav: Nav,
1425    /// The article list for the selected scope + view.
1426    entries: Vec<EntryRow>,
1427    /// The list heading (the selected view/feed/folder name).
1428    heading: String,
1429    /// Whether a feed scope is active (enables per-feed mark-all-read).
1430    feed_scope: Option<String>,
1431    /// Total CACHED entries in this scope + view across ALL pages. The count used
1432    /// to be `entries.len()`, which was the same number only because the list was
1433    /// unpaged — the thing this change exists to stop.
1434    ///
1435    /// The pager is derived from this, so it must not include the uncached PDS
1436    /// rows below: they are appended to the last page rather than paged, and
1437    /// counting them here advertised a page the clamp could never reach.
1438    total: i64,
1439    /// How many of `total` are PDS saved records the cache cannot show.
1440    ///
1441    /// A subset of `total`, not an addition to it — the heading says "N entries
1442    /// (M saved elsewhere)". An earlier version rendered "N entries, plus M",
1443    /// which double counted once `total` started including them, against an M
1444    /// that had become page-local in the same commit while the template stayed
1445    /// put.
1446    uncached_total: i64,
1447    /// 1-based current page.
1448    page: i64,
1449    /// Total pages, at least 1 (an empty list is page 1 of 1).
1450    page_count: i64,
1451    /// Link to the previous (newer) page, or `None` on the first.
1452    prev_href: Option<String>,
1453    /// Link to the next (older) page, or `None` on the last.
1454    next_href: Option<String>,
1455}
1456
1457/// The feed-management page (`GET /manage`) — subscribe / your-feeds / OPML.
1458#[derive(Template)]
1459#[template(path = "manage.html")]
1460struct ManageTemplate {
1461    /// The link card. A private view: the site's generic card, `noindex`.
1462    card: Card,
1463    version: &'static str,
1464    repo_url: &'static str,
1465    kofi_url: &'static str,
1466    flash: String,
1467    /// See [`IndexTemplate::alert`].
1468    alert: String,
1469    nav: Nav,
1470    /// All folders as move-targets for the subscribe folder select.
1471    folder_options: Vec<FolderOption>,
1472    /// Folders (each with feeds) + loose feeds, for the "Your feeds" list.
1473    folders: Vec<FolderView>,
1474    loose_feeds: Vec<FeedView>,
1475    /// `Config::standard_site`. With it on, the subscribe form says a
1476    /// `site.standard.publication` URI is accepted and its input drops
1477    /// `type="url"`, whose browser validation rejects the DID form. With it off
1478    /// `add_subscription` refuses every `at://` paste, so the form must not
1479    /// advertise one.
1480    standard_site: bool,
1481}
1482
1483/// The optional one-line adoption fact at the bottom of `/about`
1484/// (`design/NETWORK-SPEC.md` §4.4). `None` whenever the display flag is off, no
1485/// probe has succeeded yet, or the read failed — the line then simply does not
1486/// render.
1487struct AdoptionLine {
1488    /// Repos a relay has indexed as holding the subscription collection.
1489    repos: i64,
1490    /// The probe hit its page cap, so the copy must say "at least".
1491    truncated: bool,
1492    /// Observation date, `YYYY-MM-DD` (UTC), sliced from the stored RFC3339 stamp.
1493    observed_on: String,
1494}
1495
1496/// The public-experiment `/about` page — disclaimer + OSS pitch + tip link, plus
1497/// the optional adoption line.
1498#[derive(Template)]
1499#[template(path = "about.html")]
1500struct AboutTemplate {
1501    /// The link card: this page's own title and description.
1502    card: Card,
1503    version: &'static str,
1504    repo_url: &'static str,
1505    kofi_url: &'static str,
1506    adoption: Option<AdoptionLine>,
1507    /// `Config::standard_site`: whether the publications section may tell the
1508    /// reader how to subscribe to one here. See [`ManageTemplate::standard_site`].
1509    standard_site: bool,
1510}
1511
1512/// The public `/standard-site` feature page. Carries the same footer fields
1513/// as the other public pages, the standard.site flag, and the release list for
1514/// the "latest releases" call-out.
1515#[derive(Template)]
1516#[template(path = "standard_site.html")]
1517struct StandardSiteTemplate {
1518    /// The link card: this page's own title and description.
1519    card: Card,
1520    version: &'static str,
1521    repo_url: &'static str,
1522    kofi_url: &'static str,
1523    /// `Config::standard_site`: whether the page may tell a visitor how to
1524    /// subscribe to a publication here. See [`ManageTemplate::standard_site`].
1525    standard_site: bool,
1526    /// [`RELEASES`], newest first, for `templates/releases.html`.
1527    releases: &'static [Release],
1528}
1529
1530/// One tagged release, as the "latest releases" call-out
1531/// (`templates/releases.html`) shows it on `/standard-site` and the landing
1532/// page. The links are derived from `version` and `date`, so a release is
1533/// described in exactly one place: an entry in [`RELEASES`].
1534pub(crate) struct Release {
1535    /// The crate version, without the `v` (`"0.4.1"`). The tag is `v{version}`.
1536    pub(crate) version: &'static str,
1537    /// The release date, `YYYY-MM-DD`, as the CHANGELOG heading has it.
1538    pub(crate) date: &'static str,
1539    /// One or two plain sentences for a visitor. No markup: the template escapes it.
1540    pub(crate) summary: &'static str,
1541}
1542
1543impl Release {
1544    /// The GitHub release page: `{REPO_URL}/releases/tag/v{version}`.
1545    pub(crate) fn url(&self) -> String {
1546        format!("{REPO_URL}/releases/tag/v{}", self.version)
1547    }
1548
1549    /// The release's section of `CHANGELOG.md` on `main`. GitHub derives the
1550    /// anchor for a heading `## 0.4.1 — 2026-10-04` as `041--2026-10-04`: the
1551    /// dots dropped, the em dash dropped, each space a hyphen.
1552    pub(crate) fn changelog_url(&self) -> String {
1553        format!(
1554            "{REPO_URL}/blob/main/CHANGELOG.md#{}--{}",
1555            self.version.replace('.', ""),
1556            self.date
1557        )
1558    }
1559}
1560
1561/// **The one place a release is described for the website.** Newest first.
1562/// To announce the next release, add one entry at the top; the call-out on
1563/// `/standard-site` and the landing page, and both links, follow from it.
1564/// `releases_are_newest_first_and_link_the_tag_and_changelog` pins the shape.
1565pub(crate) const RELEASES: &[Release] = &[
1566    Release {
1567        version: "0.4.3",
1568        date: "2026-10-05",
1569        summary: "Two write-path fixes for any PDS: large OPML imports and \
1570                  read-state syncs are sent in calls the PDS accepts, and a \
1571                  read-state sync that disagreed with the PDS recovers instead \
1572                  of failing every round.",
1573    },
1574    Release {
1575        version: "0.4.2",
1576        date: "2026-10-04",
1577        summary: "A public standard.site feature page with this list of recent \
1578                  releases, and link cards: a posted feather-reader.com link \
1579                  now unfurls with a description and an image.",
1580    },
1581    Release {
1582        version: "0.4.1",
1583        date: "2026-10-04",
1584        summary: "The public pages explain standard.site publications, and the \
1585                  subscribe form can submit the DID form of a publication URI, \
1586                  which browsers refused in 0.4.0.",
1587    },
1588    Release {
1589        version: "0.4.0",
1590        date: "2026-10-03",
1591        summary: "standard.site support: publications are read from their \
1592                  authors' atproto repos as subscriptions, beside RSS, on their \
1593                  own polling loop. Every stored field from a feed or a \
1594                  publication now has a size bound.",
1595    },
1596];
1597
1598/// The public `/stats` page — is the poller keeping up?
1599///
1600/// Aggregate only, deliberately. It is published to anyone, so it carries no
1601/// user counts and no per-feed detail: a reader does not need to know how many
1602/// people use an instance or which feeds are failing. What it does answer is the
1603/// question that decides whether an instance can take more readers — whether the
1604/// poller is servicing the feeds it already has.
1605///
1606/// The counts below are aggregate machine facts, which is why they fit that
1607/// contract: "12 feeds are in backoff" names no feed and no reader, while
1608/// answering the question the page was previously unable to answer at all.
1609#[derive(Template)]
1610#[template(path = "stats.html")]
1611struct StatsTemplate {
1612    /// The link card: this page's own title and description.
1613    card: Card,
1614    version: &'static str,
1615    repo_url: &'static str,
1616    kofi_url: &'static str,
1617    feeds_tracked: i64,
1618    polled_last_hour: i64,
1619    polled_pct: i64,
1620    overdue: i64,
1621    last_poll: String,
1622    oldest_poll: String,
1623    never_polled: i64,
1624    poll_interval_mins: i64,
1625    /// Feeds in error backoff. Invisible before, and excluded from `overdue`.
1626    in_backoff: i64,
1627    /// Of those, the ones retried hours apart rather than minutes. **Not
1628    /// "effectively dead"** — see `store::BADLY_BROKEN_ERRORS`; they recover on
1629    /// their next successful poll, and most of this instance's did.
1630    badly_broken: i64,
1631    /// Failing feeds by cause, descending — counts only, never which feed.
1632    failure_kinds: Vec<(String, i64)>,
1633    /// What the poller is actually doing: `running`, `paused` (at the size
1634    /// watermark), `starting` (no tick completed yet) or `off` (schedulers
1635    /// disabled). Three of those four used to render as "running".
1636    fetching: &'static str,
1637}
1638
1639/// The public `/privacy` page — what the server holds vs. what lives in the
1640/// user's PDS. Carries the same `repo_url`/`kofi_url`/`version` the shared
1641/// footer include needs.
1642#[derive(Template)]
1643#[template(path = "privacy.html")]
1644struct PrivacyTemplate {
1645    /// The link card: this page's own title and description.
1646    card: Card,
1647    version: &'static str,
1648    repo_url: &'static str,
1649    kofi_url: &'static str,
1650}
1651
1652/// The public `/terms` page — the as-is / no-warranty terms of use. Carries the
1653/// same fields the shared footer include needs.
1654#[derive(Template)]
1655#[template(path = "terms.html")]
1656struct TermsTemplate {
1657    /// The link card: this page's own title and description.
1658    card: Card,
1659    version: &'static str,
1660    repo_url: &'static str,
1661    kofi_url: &'static str,
1662}
1663
1664/// The signed-out landing page (`GET /` with no session) — the public front
1665/// door at feather-reader.com. A static render, no session required.
1666#[derive(Template)]
1667#[template(path = "landing.html")]
1668struct LandingTemplate {
1669    /// The link card: the site's own title and description.
1670    card: Card,
1671    version: &'static str,
1672    repo_url: &'static str,
1673    crates_url: &'static str,
1674    kofi_url: &'static str,
1675    /// `Config::standard_site`: whether the publications point may tell a
1676    /// visitor how to subscribe to one here. See [`ManageTemplate::standard_site`].
1677    standard_site: bool,
1678    /// [`RELEASES`], newest first, for the quiet "latest releases" strip.
1679    releases: &'static [Release],
1680}
1681
1682/// The single-entry reader view (`GET /entries/:id`).
1683#[derive(Template)]
1684#[template(path = "entry.html")]
1685struct EntryTemplate {
1686    /// The link card. A private view: the site's generic card, `noindex`.
1687    card: Card,
1688    version: &'static str,
1689    repo_url: &'static str,
1690    kofi_url: &'static str,
1691    nav: Nav,
1692    id: i64,
1693    title: String,
1694    feed_title: String,
1695    author: Option<String>,
1696    published: String,
1697    /// The entry's own link, for `entry.html`'s two `href`s.
1698    ///
1699    /// `Option<SafeLink>`, not `Option<String>`: the column it comes from holds
1700    /// a remote feed's `<link>`. Ingest scheme-checks it, but that guard is a
1701    /// long way from the `href` and holds only while every future writer to
1702    /// `entries.url` remembers to go through `feed.rs` — the same procedural
1703    /// defence that, on the saved-record row, turned out to be deletable with
1704    /// all 679 tests still green. `None` is the refusal: the template's
1705    /// no-URL branch already renders a disabled open-original button.
1706    url: Option<SafeLink>,
1707    content_html: Option<String>,
1708    read: bool,
1709    starred: bool,
1710    /// The query string to carry the reading context back to the list.
1711    back_qs: String,
1712    /// Prev/next entry ids within the current list, for keyboard/paging nav.
1713    prev_id: Option<i64>,
1714    next_id: Option<i64>,
1715    /// Rendered inline (not an out-of-band swap fragment): always `false` here.
1716    oob: bool,
1717}
1718
1719/// The htmx swap fragment for a single entry row (`entry_row.html`).
1720#[derive(Template)]
1721#[template(path = "entry_row.html")]
1722struct EntryRowTemplate {
1723    e: EntryRow,
1724}
1725
1726/// The reader's action-bar fragment (`entry_actionbar.html`) returned as an
1727/// out-of-band swap after a mark-read / star toggle FROM THE READER, so the
1728/// button's hidden value + `aria-pressed` update in place (the reader `<li>`
1729/// isn't in the DOM to swap, unlike the list view's `entry_row.html`).
1730#[derive(Template)]
1731#[template(path = "entry_actionbar.html")]
1732struct EntryActionBarTemplate {
1733    id: i64,
1734    read: bool,
1735    starred: bool,
1736    /// Emit the `hx-swap-oob` attribute: `true` for the handler's OOB response.
1737    oob: bool,
1738}
1739
1740/// The login stub (`GET /login`).
1741#[derive(Template)]
1742#[template(path = "login.html")]
1743struct LoginTemplate {
1744    /// The link card: this page's own title and description.
1745    card: Card,
1746    repo_url: &'static str,
1747    error: String,
1748    /// A neutral/success banner (e.g. the post-delete "signed out" confirmation),
1749    /// distinct from `error`. Empty renders nothing.
1750    flash: String,
1751}
1752
1753/// The closed-beta invite-redeem page (`GET /beta/redeem`).
1754#[derive(Template)]
1755#[template(path = "beta_redeem.html")]
1756struct BetaRedeemTemplate {
1757    /// The link card: this page's own title and description.
1758    card: Card,
1759    repo_url: &'static str,
1760    error: String,
1761    /// When true the seat cap is full: hide the form and show the "capacity
1762    /// full — try self-hosting" message instead.
1763    capacity_full: bool,
1764}
1765
1766// ---------------------------------------------------------------------------
1767// Rendering + error helpers
1768// ---------------------------------------------------------------------------
1769
1770/// Render an askama template into an HTML response, mapping a render failure to
1771/// a `500` rather than panicking (no `unwrap` in the request path).
1772fn render<T: Template>(tmpl: &T) -> Response {
1773    match tmpl.render() {
1774        Ok(body) => Html(body).into_response(),
1775        Err(err) => {
1776            warn!(%err, "template render failed");
1777            (StatusCode::INTERNAL_SERVER_ERROR, "template render error").into_response()
1778        }
1779    }
1780}
1781
1782/// A minimal web error type so handlers can `?`-propagate `anyhow` failures and
1783/// still return an `impl IntoResponse`. Renders as a `500` with a short message
1784/// by default; a handler may override the status (e.g. `413` for an over-cap
1785/// upload) via [`WebError::with_status`].
1786struct WebError {
1787    err: anyhow::Error,
1788    status: StatusCode,
1789}
1790
1791impl<E: Into<anyhow::Error>> From<E> for WebError {
1792    fn from(err: E) -> Self {
1793        WebError {
1794            err: err.into(),
1795            status: StatusCode::INTERNAL_SERVER_ERROR,
1796        }
1797    }
1798}
1799
1800impl WebError {
1801    /// Attach an explicit HTTP status to render instead of the default `500`.
1802    fn with_status(err: impl Into<anyhow::Error>, status: StatusCode) -> Self {
1803        WebError {
1804            err: err.into(),
1805            status,
1806        }
1807    }
1808}
1809
1810impl IntoResponse for WebError {
1811    fn into_response(self) -> Response {
1812        warn!(error = %self.err, status = %self.status, "request failed");
1813        let body = if self.status == StatusCode::INTERNAL_SERVER_ERROR {
1814            "internal error"
1815        } else {
1816            self.status.canonical_reason().unwrap_or("error")
1817        };
1818        (self.status, body).into_response()
1819    }
1820}
1821
1822/// Map an axum [`MultipartError`] to a [`WebError`] that preserves the error's
1823/// own HTTP status. When a request exceeds the route's `DefaultBodyLimit` the
1824/// multipart extractor reports `413 Payload Too Large`; a malformed body reports
1825/// `400`. Either way this avoids collapsing the failure into a generic `500`.
1826fn multipart_response(err: axum::extract::multipart::MultipartError) -> WebError {
1827    let status = err.status();
1828    WebError::with_status(err, status)
1829}
1830
1831/// A short, human display of a feed/site title for the sidebar/list, falling
1832/// back to the host of a URL and finally to the raw string.
1833fn display_title(title: Option<&str>, url: &str) -> String {
1834    if let Some(t) = title {
1835        let t = t.trim();
1836        if !t.is_empty() {
1837            return t.to_string();
1838        }
1839    }
1840    url::Url::parse(url)
1841        .ok()
1842        .and_then(|u| u.host_str().map(str::to_string))
1843        .unwrap_or_else(|| url.to_string())
1844}
1845
1846/// A display `@handle` for the identity chip: the stored handle if present,
1847/// else the tail of the DID so the chip is never empty.
1848fn display_handle(handle: Option<&str>, did: &str) -> String {
1849    match handle {
1850        Some(h) if !h.trim().is_empty() => format!("@{}", h.trim().trim_start_matches('@')),
1851        _ => did.rsplit(':').next().unwrap_or(did).to_string(),
1852    }
1853}
1854
1855/// Two-letter, lowercase avatar initials from a handle/DID.
1856fn avatar_initials(handle: Option<&str>, did: &str) -> String {
1857    let source = handle
1858        .map(|h| h.trim().trim_start_matches('@'))
1859        .filter(|h| !h.is_empty())
1860        .unwrap_or_else(|| did.rsplit(':').next().unwrap_or(did));
1861    let letters: String = source
1862        .chars()
1863        .filter(|c| c.is_alphanumeric())
1864        .take(2)
1865        .collect::<String>()
1866        .to_lowercase();
1867    if letters.is_empty() {
1868        "fr".to_string()
1869    } else {
1870        letters
1871    }
1872}
1873
1874/// Trim a stored RFC3339 timestamp down to the `YYYY-MM-DD` date for calm,
1875/// low-noise display. Falls back to the raw string if it doesn't look like one.
1876fn display_date(published: Option<&str>) -> String {
1877    // CHARACTERS, not bytes. `p[..10]` panics when byte 10 lands inside a
1878    // multi-byte character, and every caller used to pass a timestamp the feed
1879    // parser had produced. The saved-record path passes `createdAt` straight off
1880    // a PDS record, which the lexicon types as a bare string with no validation
1881    // — written by whatever atproto client the reader used. A `createdAt` of
1882    // "日本語日本語日本" took down the whole starred view, and there is no
1883    // catch-panic layer in the stack, so the page stayed down until the record
1884    // was removed from the very view that would not render.
1885    match published {
1886        Some(p) => p.chars().take(10).collect(),
1887        None => String::new(),
1888    }
1889}
1890
1891/// Percent-encode a value for use in a query string (RFC 3986 unreserved kept).
1892/// Small and dependency-free — the `url` crate's form-encoding isn't exposed for
1893/// a bare value, and this keeps the scope-preserving links honest.
1894fn qenc(s: &str) -> String {
1895    let mut out = String::with_capacity(s.len() * 3);
1896    for b in s.bytes() {
1897        match b {
1898            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
1899                out.push(b as char)
1900            }
1901            _ => out.push_str(&format!("%{b:02X}")),
1902        }
1903    }
1904    out
1905}
1906
1907// ---------------------------------------------------------------------------
1908// Reader: index
1909// ---------------------------------------------------------------------------
1910
1911/// Query for `GET /` — the scope + view selector.
1912#[derive(Debug, Deserialize, Default)]
1913struct IndexQuery {
1914    /// Filter to a single feed by its canonical URL.
1915    #[serde(default)]
1916    feed: Option<String>,
1917    /// Filter to a folder by its `at://` URI (shows every feed in the folder).
1918    #[serde(default)]
1919    folder: Option<String>,
1920    /// `unread` (default) | `all` | `starred`.
1921    #[serde(default)]
1922    view: Option<String>,
1923    /// 1-based page within the selected scope + view. Absent/0 means page 1.
1924    #[serde(default)]
1925    page: Option<u32>,
1926    /// Optional flash message (e.g. after an action redirect).
1927    #[serde(default)]
1928    flash: Option<String>,
1929}
1930
1931/// Rows per page in the reader's list views.
1932///
1933/// The list projection no longer carries article bodies ([`store::EntryListRow`]),
1934/// so a page is on the order of tens of kilobytes rather than the tens or
1935/// hundreds of megabytes an unbounded list of full entries could reach. The page
1936/// bound is the second half of that fix: without it, a reader with a long
1937/// backlog still decides how much memory a single request allocates.
1938const ENTRIES_PER_PAGE: i64 = 100;
1939
1940/// How many pages `total` entries occupy. An empty list is page 1 of 1, so the
1941/// pager reads "1 / 1" rather than "1 / 0".
1942fn page_count_for(total: i64) -> i64 {
1943    ((total + ENTRIES_PER_PAGE - 1) / ENTRIES_PER_PAGE).max(1)
1944}
1945
1946/// Ceiling on the reader's prev/next id list.
1947///
1948/// Unlike the page above, this genuinely spans the whole list — prev/next is the
1949/// reader's position within it — so it is bounded by count rather than paged. At
1950/// 8 bytes per id this is ~40 KB at the cap. Past it the neighbour links stop
1951/// resolving; the article itself still opens, and the list view still pages.
1952const PREV_NEXT_MAX: i64 = 5_000;
1953
1954/// Ceiling on the cached-starred identity set matched against PDS saved records.
1955///
1956/// Deliberately generous: under-reading this set makes a cached article look
1957/// uncached, and an uncached starred row's button deletes the PDS RECORD rather
1958/// than un-starring the entry. Truncating here would change what a click
1959/// destroys, so the cap exists only as a backstop against an absurd starred
1960/// count, not as a routine bound.
1961const STARRED_IDENTITY_MAX: i64 = 20_000;
1962
1963/// Most uncached PDS saved records this handler will hold in memory for one
1964/// request.
1965///
1966/// **A memory bound, not a visibility bound.** These rows are PAGED alongside
1967/// the cached entries, so `ENTRIES_PER_PAGE` decides how many are rendered and
1968/// this only caps how many are collected before slicing. An earlier version used
1969/// it to cap what was SHOWN, which left everything past it invisible and —
1970/// because the un-save control lives on the row, and nothing else in the app
1971/// lists these — unremovable.
1972///
1973/// Well above the PDS list ceiling's practical reach for one reader, so a reader
1974/// meeting it has thousands of saved records and gets a logged, ordered prefix
1975/// rather than a failure.
1976const MAX_UNCACHED_SAVED_ROWS: usize = 5_000;
1977
1978/// A subscription resolved against the local cache: the PDS record + its
1979/// (possibly-missing) cached feed row.
1980struct ResolvedSub {
1981    rkey: String,
1982    sub: Subscription,
1983    feed: Option<store::Feed>,
1984}
1985
1986/// Pull the user's subscriptions (source of truth = PDS), ensure each has a
1987/// local cache row so unread counts work, and return them resolved. Best-effort
1988/// on the sidecar: a failure falls back to the local cache alone.
1989async fn resolve_subscriptions(state: &AppState, did: &str) -> Vec<ResolvedSub> {
1990    resolve_subscriptions_noting(state, did).await.0
1991}
1992
1993/// What to tell a reader whose subscription list could not be read from their
1994/// PDS, so the last-known list being shown does not pass for a fresh one.
1995///
1996/// **A malformed record is named as such** (#177): the walk refuses rather than
1997/// drop that subscription, and "unreachable" would send the reader looking at
1998/// their network when the cause is a record some client wrote into their repo.
1999fn subscriptions_alert(err: &anyhow::Error) -> String {
2000    match err.downcast_ref::<crate::atproto::MalformedRecords>() {
2001        Some(m) => format!(
2002            "{} record(s) in your subscription list could not be read, so it was not \
2003             refreshed. Showing your last-known subscriptions; nothing was removed.",
2004            m.count
2005        ),
2006        None => "Your subscription list could not be read from your PDS just now. \
2007                 Showing your last-known subscriptions."
2008            .to_string(),
2009    }
2010}
2011
2012/// [`resolve_subscriptions`], plus the alert to show when the list shown is the
2013/// cached one because the PDS listing failed.
2014async fn resolve_subscriptions_noting(
2015    state: &AppState,
2016    did: &str,
2017) -> (Vec<ResolvedSub>, Option<String>) {
2018    let pool = &state.db;
2019    let subs = match state.repo().list_subscriptions_sorted(did).await {
2020        Ok(s) => s,
2021        Err(err) => {
2022            let alert = subscriptions_alert(&err);
2023            warn!(%err, %did, "could not list PDS subscriptions; showing this DID's cached subscriptions only");
2024            // Fail CLOSED: the PDS is the source of truth for what this DID
2025            // follows. When it is unreachable we must NOT widen the caller's
2026            // authorization surface. Serve from the DID's OWN last-known
2027            // `sub_ref` projection (its own feeds, possibly stale) and leave
2028            // `sub_ref` untouched — never synthesize from every cached feed,
2029            // which would grant cross-tenant read+mutate during any outage.
2030            // A DB failure here is NOT the same as "this DID follows nothing",
2031            // but `unwrap_or_default` rendered it as exactly that: an empty
2032            // sidebar and an empty reader, which arrives as "all my feeds
2033            // vanished". It still degrades to empty — there is nothing better to
2034            // show — but it says so, so the support ticket and the log line can
2035            // be matched up.
2036            let feeds = store::feeds_for_did(pool, did).await.unwrap_or_else(|err| {
2037                warn!(%err, %did, "the PDS is unreachable AND the local subscription \
2038                                   projection could not be read; rendering an EMPTY \
2039                                   feed list, which is not the same as having none");
2040                Vec::new()
2041            });
2042            let cached = feeds
2043                .into_iter()
2044                .map(|f| ResolvedSub {
2045                    rkey: String::new(),
2046                    sub: Subscription::new(f.url.clone(), now_rfc3339()),
2047                    feed: Some(f),
2048                })
2049                .collect();
2050            return (cached, Some(alert));
2051        }
2052    };
2053
2054    // **Deliberately NOT truncated to `max_subs_per_did`.**
2055    //
2056    // The PDS list is unbounded in practice — any client can write subscription
2057    // records, and only the 20,000-record list ceiling stops it — and the first
2058    // attempt at bounding it truncated the list right here. That was the wrong
2059    // place: `sync_sub_refs` below writes `sub_ref` from exactly this set, and
2060    // `sub_ref` is THE per-DID authorization hook, so dropping entries silently
2061    // removed the reader's ability to read OR mutate those feeds. A query-shape
2062    // problem would have become an access problem.
2063    //
2064    // The shape problem was the scope filter emitting one SQL placeholder per
2065    // feed; `store::list_query_sql` now passes the whole set as a single
2066    // `json_each` bind, so there is no size to defend against here and nothing
2067    // to truncate. `max_subs_per_did` stays what it is — a policy cap on ADDING
2068    // feeds — rather than becoming a silent read-time filter.
2069    let mut out = Vec::with_capacity(subs.len());
2070    for (rkey, sub) in subs {
2071        let feed = match store::get_feed_by_url(pool, &sub.url).await {
2072            Ok(Some(f)) => Some(f),
2073            Ok(None) => {
2074                // `sub.url` came out of an atproto record. The lexicon is open —
2075                // ANY client can write a subscription into a user's repo — so
2076                // this is untrusted input on the hot path of `GET /`, and it was
2077                // being stored with none of the three checks the add and import
2078                // paths apply. Two of those are capacity ceilings; this one is
2079                // the invariant in `FeedPrivacy`'s doc comment, which promises a
2080                // private feed URL is "never stored". Writing a
2081                // `…/feed/private/<token>` into the SHARED `feeds` table breaks
2082                // that promise even though `net::guarded_get` still refuses to
2083                // fetch it.
2084                if !feed::is_storable_feed_url(&sub.url, state.config.standard_site)
2085                    || feed::classify_feed_privacy(&sub.url).is_private()
2086                {
2087                    warn!(
2088                        %did,
2089                        "skipping cache row for a subscription URL that is private or not http(s)"
2090                    );
2091                    out.push(ResolvedSub {
2092                        rkey,
2093                        sub,
2094                        feed: None,
2095                    });
2096                    continue;
2097                }
2098                // Upsert a cache row so the sidebar reflects the real follow-list.
2099                //
2100                // A silent failure here is a support ticket with no evidence: no
2101                // `feeds` row means the poller never selects this subscription,
2102                // so the reader sees "I added a feed and it never updates" while
2103                // the PDS record looks perfect. Logged with the URL so the
2104                // failing subscription is identifiable.
2105                if let Err(err) = store::upsert_feed(
2106                    pool,
2107                    &store::NewFeed {
2108                        url: sub.url.clone(),
2109                        title: sub.title.clone(),
2110                        site_url: sub.site_url.clone(),
2111                        ..Default::default()
2112                    },
2113                )
2114                .await
2115                {
2116                    warn!(%err, url = %sub.url, %did, "could not cache a subscribed feed; \
2117                                                       it will not be polled");
2118                }
2119                store::get_feed_by_url(pool, &sub.url).await.ok().flatten()
2120            }
2121            Err(err) => {
2122                warn!(%err, url = %sub.url, "get_feed_by_url failed");
2123                None
2124            }
2125        };
2126        out.push(ResolvedSub { rkey, sub, feed });
2127    }
2128    // Mirror the caller's resolved subscription set into `sub_ref`, so every
2129    // scoped entry/feed read + read/star mutation authorizes against exactly
2130    // the feeds this DID follows right now. This is THE per-DID isolation hook.
2131    sync_sub_refs(pool, did, &out).await;
2132    (out, None)
2133}
2134
2135/// Refresh the `sub_ref` projection for `did` to exactly the feed ids present
2136/// in `subs`. Best-effort: a failure here only degrades the scoped reads (they
2137/// fail closed / show fewer rows), never leaks another user's entries.
2138async fn sync_sub_refs(pool: &store::Pool, did: &str, subs: &[ResolvedSub]) {
2139    let feed_ids: Vec<i64> = subs
2140        .iter()
2141        .filter_map(|s| s.feed.as_ref().map(|f| f.id))
2142        .collect();
2143    if let Err(err) = store::replace_sub_refs(pool, did, &feed_ids).await {
2144        warn!(%err, %did, "failed to sync sub_ref projection");
2145    }
2146}
2147
2148/// `GET /` — the reader. Renders the sidebar (folders + feeds from the PDS
2149/// records layer) and the article list for the selected scope + view.
2150async fn index(
2151    State(state): State<AppState>,
2152    headers: HeaderMap,
2153    Query(q): Query<IndexQuery>,
2154) -> Result<Response, WebError> {
2155    let user = match current_session(&state, &headers).await {
2156        Some(u) => u,
2157        // Signed out: serve the public landing page rather than bouncing to
2158        // /login. /login remains the entry point for the actual OAuth sign-in.
2159        None => {
2160            return Ok(render(&LandingTemplate {
2161                card: Card::site(&state.config),
2162                version: VERSION,
2163                repo_url: REPO_URL,
2164                crates_url: CRATES_URL,
2165                kofi_url: KOFI_URL,
2166                standard_site: state.config.standard_site,
2167                releases: RELEASES,
2168            }))
2169        }
2170    };
2171    let did = user.did.clone();
2172    let pool = &state.db;
2173
2174    let (subs, alert) = resolve_subscriptions_noting(&state, &did).await;
2175
2176    // View: unread (default) | all | starred.
2177    let view = match q.view.as_deref() {
2178        Some("all") => "all",
2179        Some("starred") => "starred",
2180        _ => "unread",
2181    }
2182    .to_string();
2183    let list_view = list_view_of(q.view.as_deref());
2184
2185    // Which feed URLs are in scope?
2186    let scope_urls = scope_urls_for(&subs, q.feed.as_deref(), q.folder.as_deref());
2187    // …and the feed ids they resolve to. Scope is applied inside the query now,
2188    // so a page is a page of rows the reader will actually see. Filtering after
2189    // a `LIMIT` would have made pages arbitrarily short — sometimes empty — for
2190    // any scope narrower than the whole subscription list.
2191    let scope_ids = scoped_feed_ids(&subs, &scope_urls);
2192
2193    let feed_title_by_id = |id: i64| -> String {
2194        subs.iter()
2195            .find(|s| s.feed.as_ref().map(|f| f.id) == Some(id))
2196            .map(|s| {
2197                display_title(
2198                    s.sub
2199                        .title
2200                        .as_deref()
2201                        .or(s.feed.as_ref().and_then(|f| f.title.as_deref())),
2202                    &s.sub.url,
2203                )
2204            })
2205            .unwrap_or_default()
2206    };
2207
2208    // **One page of the chosen view, filtered, ordered and bounded in SQL.**
2209    //
2210    // All three views used to materialize every matching entry — `SELECT e.*`,
2211    // no `LIMIT`, article bodies included — and the "all" view additionally ran
2212    // one such query PER SUBSCRIBED FEED and merged the results in memory. None
2213    // of the row fields below read the body. See `store::EntryListRow`.
2214    // **Saved records the cache cannot show.**
2215    //
2216    // The starred view is built from local `entries`, so a saved record whose
2217    // article was never cached here is invisible — the case that matters is
2218    // starring in ANOTHER atproto reader, which is the portability the shared
2219    // lexicon exists for. Those rows are rendered from the PDS record alone.
2220    let mut uncached: Vec<EntryRow> = Vec::new();
2221    if view == "starred" {
2222        // **Match against every SUBSCRIBED cached starred entry, not `source`.**
2223        //
2224        // `source` has already been filtered by feed/folder. Matching against it
2225        // meant an entry that IS cached but sits outside the current filter
2226        // looked uncached — so it rendered as a "not cached" row whose star
2227        // button deletes the PDS RECORD instead of un-starring the entry. A
2228        // scope filter must not change what is destroyed. Paging is the same
2229        // hazard in a new form: matching against the visible PAGE would make
2230        // every cached article outside it look uncached. Hence a dedicated
2231        // identity query over the whole starred set — urls and guids only, no
2232        // bodies — rather than reusing `source`.
2233        //
2234        // One gap remains BY DESIGN, and is handled at the other end. This query
2235        // still carries the `sub_ref` predicate, so a starred, cached entry in a
2236        // feed the reader has UNSUBSCRIBED from is absent here and its record
2237        // renders as uncached. That is the right rendering — the article is no
2238        // longer part of any feed the reader follows, and the PDS record is what
2239        // still holds it — but it means the un-save button is the record-deleting
2240        // one. `unsave_record` therefore clears the local star too, so the two
2241        // stores agree however the row got classified. Dropping the predicate
2242        // here instead would have made the row link to `/entries/{id}`, which is
2243        // `sub_ref`-scoped and would 404.
2244        //
2245        // **Three ways this can be unusable, and all three fail CLOSED.** With an
2246        // incomplete identity set, a cached article looks uncached and renders an
2247        // un-save button that deletes the PDS RECORD. Showing no uncached rows
2248        // loses rows for one render; getting this wrong loses data permanently,
2249        // so every uncertain case suppresses them.
2250        let identities = match store::starred_identities(pool, &did, STARRED_IDENTITY_MAX).await {
2251            Ok(store::StarredIdentities::All(rows)) => Some(rows),
2252            // The cap is a memory backstop, and reaching it means the set is an
2253            // arbitrary subset. It used to return that subset with no way to
2254            // tell, so every starred article outside it got the destructive
2255            // button.
2256            Ok(store::StarredIdentities::Truncated) => {
2257                warn!(
2258                    %did,
2259                    cap = STARRED_IDENTITY_MAX,
2260                    "cached-starred set exceeded its cap; suppressing uncached saved rows \
2261                     rather than rendering record-deleting buttons for cached articles"
2262                );
2263                None
2264            }
2265            Err(err) => {
2266                warn!(%err, %did, "cached-starred identity lookup failed; \
2267                                    suppressing uncached saved rows this render");
2268                None
2269            }
2270        };
2271        // The escape hatch asks whether this DID has ANY cached starred entry —
2272        // not whether the current SCOPE does. `total` is narrowed by
2273        // `?feed=`/`?folder=` while the identity set spans every feed, so
2274        // comparing them waved the fail-closed condition through for any narrow
2275        // scope: a record whose `feedUrl` matched the filter while its cached
2276        // entry lived under another feed rendered as uncached.
2277        let identities_ok = identities.is_some();
2278        let identities = identities.unwrap_or_default();
2279        let cached_urls: std::collections::HashSet<&str> = identities
2280            .iter()
2281            .filter_map(|(url, _)| url.as_deref())
2282            .collect();
2283        let cached_guids: std::collections::HashSet<&str> =
2284            identities.iter().map(|(_, guid)| guid.as_str()).collect();
2285
2286        // Collected in full here, sliced per page later. They sort after every
2287        // cached row, so the two lists form one sequence that the pager walks —
2288        // see the slice below. Collected BEFORE the page is chosen because the
2289        // page count depends on how many there are.
2290        // Bounded like everything else on this page. These come from the PDS
2291        // (up to the list ceiling — 20,000 on the sidecar backend, 5,000 on
2292        // `backend=rust`, whose caps are a quarter of the other's) and are
2293        // appended whole to the last page, so `ENTRIES_PER_PAGE` does not
2294        // constrain them at all. The
2295        // cap is generous — a reader with more saved-elsewhere records than this
2296        // is not the case being designed for — but a response has to have a size
2297        // an operator can reason about.
2298        let mut uncached_dropped = 0usize;
2299        match state.repo().list_saved_sorted(&did).await {
2300            Ok(saved) if identities_ok => {
2301                for (rkey, item) in saved {
2302                    let known = cached_urls.contains(item.url.as_str())
2303                        || item
2304                            .entry_id
2305                            .as_deref()
2306                            .is_some_and(|g| cached_guids.contains(g));
2307                    if known {
2308                        continue;
2309                    }
2310                    // And the scope filter applies to these rows too. Without
2311                    // it, `?feed=X` still listed saved records from every other
2312                    // feed — the filter silently did nothing for them.
2313                    if let Some(urls) = &scope_urls {
2314                        match item.feed_url.as_deref() {
2315                            Some(feed_url) if urls.iter().any(|u| u == feed_url) => {}
2316                            // A saved record with no `feedUrl` cannot be placed
2317                            // in any feed's scope, so it belongs only to the
2318                            // unfiltered view.
2319                            _ => continue,
2320                        }
2321                    }
2322                    // **`safe_link` FIRST, and a failure no longer drops the row.**
2323                    //
2324                    // `item.url` is attacker-controlled — a saved record written
2325                    // by any client — and it lands in an `href`. Askama escapes
2326                    // HTML metacharacters but not SCHEMES, so `javascript:`
2327                    // survives escaping intact. This project already built the
2328                    // helper for exactly that, and `feed.rs` uses it on the
2329                    // equivalent link; this path was simply not routed through it.
2330                    //
2331                    // The real defect was what a failure DID: it `continue`d, so
2332                    // the row vanished entirely — no badge, no count, nothing —
2333                    // and the only trace was a `debug!` below any realistic
2334                    // filter. That makes the record unremovable FROM HERE, because
2335                    // the un-save button lives on the row; the reader has to open
2336                    // a different atproto client to get rid of it. A bad URL is a
2337                    // reason to withhold the LINK, not the row.
2338                    //
2339                    // The check also moved ABOVE the poll nudge. That is ordering
2340                    // hygiene rather than a fix: the nudge keys on `feed_url`, not
2341                    // on the URL being rejected here, and is already gated on the
2342                    // reader actually subscribing to that feed — so it was never
2343                    // reachable by an unusable `item.url`. Deciding whether a
2344                    // record is renderable before doing anything outbound on its
2345                    // behalf is simply the order that stays correct if either of
2346                    // those two facts later stops being true.
2347                    let link = SafeLink::external(&item.url);
2348                    if link.is_empty() {
2349                        warn!(
2350                            %did, %rkey,
2351                            "a saved record has an unusable URL; rendering it without a link \
2352                             so it can still be removed"
2353                        );
2354                    }
2355
2356                    // Opportunistic re-fetch: if the reader still subscribes to
2357                    // the feed, make it due now. If the article is still inside
2358                    // the feed's window the poller caches it normally and this
2359                    // row becomes a real entry on its own — no synthetic rows in
2360                    // the shared cache, which every subscriber would otherwise
2361                    // see as a content-less entry.
2362                    // **Bound the WORK, not just the response.** This check sat
2363                    // after the nudge and the `subs` scan below, so every render
2364                    // still walked all ≤20,000 PDS records, ran a subs-length
2365                    // string scan per record, and issued up to that many
2366                    // `mark_feed_due` round-trips on a 5-connection pool — then
2367                    // discarded everything past the cap. A cap that runs after
2368                    // the expensive part is a cap on the output only.
2369                    if uncached.len() >= MAX_UNCACHED_SAVED_ROWS {
2370                        uncached_dropped += 1;
2371                        continue;
2372                    }
2373                    if let Some(feed_url) = item.feed_url.as_deref() {
2374                        if subs.iter().any(|s| s.sub.url == feed_url) {
2375                            // Bounded to one nudge per feed per poll interval —
2376                            // see `mark_feed_due`. Unbounded, a reload loop here
2377                            // becomes outbound amplification.
2378                            let stale_before = (chrono::Utc::now()
2379                                - chrono::Duration::from_std(state.config.poll_interval)
2380                                    .unwrap_or_else(|_| chrono::Duration::hours(1)))
2381                            .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
2382                            if let Err(err) =
2383                                store::mark_feed_due(pool, feed_url, &stale_before).await
2384                            {
2385                                tracing::debug!(%err, %feed_url, "could not nudge a feed for a saved article");
2386                            }
2387                        }
2388                    }
2389                    uncached.push(EntryRow {
2390                        id: 0,
2391                        title: item
2392                            .title
2393                            .clone()
2394                            .filter(|t| !t.trim().is_empty())
2395                            // Falling back to the URL is fine for a link we are
2396                            // willing to render, and wrong for one we are not:
2397                            // it would put the exact string `safe_link` just
2398                            // rejected into the page as the record's name. The
2399                            // rkey is what the un-save button acts on, so it is
2400                            // the honest identifier for a row that has nothing
2401                            // else trustworthy to show.
2402                            .unwrap_or_else(|| {
2403                                if link.is_empty() {
2404                                    format!("Saved item {rkey}")
2405                                } else {
2406                                    item.url.clone()
2407                                }
2408                            }),
2409                        feed_title: item.feed_url.clone().unwrap_or_default(),
2410                        published: display_date(Some(&item.created_at)),
2411                        read: false,
2412                        starred: true,
2413                        // Empty = "render this row without an anchor". The
2414                        // template branches on it, so the rejected URL never
2415                        // reaches an `href` even as an escaped string.
2416                        link,
2417                        cached: false,
2418                        rkey,
2419                    });
2420                }
2421            }
2422            // Identity lookup was unusable — see the fail-closed note above.
2423            Ok(_) => {}
2424            Err(err) => warn!(%err, %did, "could not list saved records from the PDS"),
2425        }
2426        if uncached_dropped > 0 {
2427            warn!(
2428                %did,
2429                dropped = uncached_dropped,
2430                cap = MAX_UNCACHED_SAVED_ROWS,
2431                "more saved records than this instance will hold in one response; the \
2432                 rest are not reachable from here"
2433            );
2434        }
2435    }
2436
2437    // **One sequence, two sources.** The cached rows come from SQL, the uncached
2438    // PDS records follow them, and the pager walks the concatenation.
2439    //
2440    // The first version appended the uncached rows to the last page only and
2441    // kept them out of `total`, which left everything past a cap invisible AND
2442    // unremovable — the un-save button lives on the row, and there is no other
2443    // surface in the app that lists these. That is the same "unremovable FROM
2444    // HERE" hazard the `safe_link` fix above exists to prevent, reintroduced
2445    // forty lines later by a bound meant to protect memory.
2446    //
2447    // Paging the concatenation makes every record reachable and needs no cap on
2448    // what is RENDERED — one page is one page either way. The version before
2449    // that inflated `total` while clamping on the cached count, which advertised
2450    // a page the clamp could never reach; both numbers come from the same total
2451    // now, which is what makes that impossible rather than merely fixed.
2452    let total_cached =
2453        store::count_entries_for_view(pool, &did, list_view, scope_ids.as_deref()).await?;
2454    let uncached_len = uncached.len();
2455    let total = total_cached + uncached_len as i64;
2456    // Clamped to the range that exists. Past the end the list is empty, and the
2457    // empty state renders instead of the pager — which would strand a reader who
2458    // typed a page number, or who paged to the end and then marked entries read
2459    // out from under their own URL. Showing the last page is the answer to both.
2460    let page = i64::from(q.page.unwrap_or(1).max(1)).min(page_count_for(total));
2461    let offset = (page - 1) * ENTRIES_PER_PAGE;
2462    // Past the cached rows this returns nothing, which is exactly right: the
2463    // page is then made up entirely of uncached ones.
2464    let source = store::list_entries(
2465        pool,
2466        &did,
2467        list_view,
2468        scope_ids.as_deref(),
2469        ENTRIES_PER_PAGE,
2470        offset,
2471    )
2472    .await?;
2473    // **Both halves of the page are computed from the COUNT alone.**
2474    //
2475    // `total_cached` (a COUNT) and `source` (a SELECT) are separate unsynchronised
2476    // queries, so they can disagree about how many cached rows exist. Any part of
2477    // the page composition that reads `source.len()` inherits that disagreement.
2478    //
2479    // `cached_allotment` is this page's cached share according to the snapshot,
2480    // and it is what the uncached `skip`/`take` are derived from — so consecutive
2481    // pages tile the uncached list exactly, whichever way the count drifted.
2482    // `source` is then truncated to it only to avoid rendering rows the next page
2483    // will also claim.
2484    //
2485    // The previous version took `skip` from the count but `take` from
2486    // `source.len()`, which agreed only when the count UNDERSTATED. Overstating —
2487    // an un-star or a retention delete landing between the two queries — made
2488    // page N render `uncached[0..70]` while page N+1 rendered `uncached[50..80]`,
2489    // putting twenty rows, each carrying the record-DELETING un-save button, on
2490    // two pages at once. The comment claimed that shape was impossible; it was
2491    // merely rarer.
2492    let cached_allotment = (total_cached - offset).clamp(0, ENTRIES_PER_PAGE) as usize;
2493    let cached_here = cached_allotment.min(source.len());
2494    // Only compose when there is something to compose WITH. `uncached` is empty
2495    // on every view but `starred`, and truncating there just drops trailing rows
2496    // that no page then shows — the poller inserting between the COUNT and the
2497    // SELECT was enough to trigger it.
2498    let source = if uncached_len == 0 {
2499        &source[..]
2500    } else {
2501        &source[..cached_here]
2502    };
2503    let uncached_page: Vec<EntryRow> = {
2504        let skip = (offset - total_cached).max(0) as usize;
2505        let take = (ENTRIES_PER_PAGE as usize) - cached_allotment;
2506        uncached.into_iter().skip(skip).take(take).collect()
2507    };
2508    // This page's slice, used only to append below. The heading needs the
2509    // WHOLE-list figure, which is the set's size before slicing.
2510    let uncached_total = uncached_len as i64;
2511
2512    // The scope/view suffix carried onto every entry link (built once).
2513    let entry_scope_qs = {
2514        let mut parts = Vec::new();
2515        if let Some(f) = q.feed.as_deref() {
2516            parts.push(format!("feed={}", qenc(f)));
2517        }
2518        if let Some(f) = q.folder.as_deref() {
2519            parts.push(format!("folder={}", qenc(f)));
2520        }
2521        if view != "unread" {
2522            parts.push(format!("view={}", qenc(&view)));
2523        }
2524        parts.join("&")
2525    };
2526    let entries: Vec<EntryRow> = source
2527        .iter()
2528        .map(|e| EntryRow {
2529            id: e.id,
2530            title: e
2531                .title
2532                .clone()
2533                .filter(|t| !t.trim().is_empty())
2534                .unwrap_or_else(|| "(untitled)".to_string()),
2535            feed_title: feed_title_by_id(e.feed_id),
2536            published: display_date(e.published.as_deref()),
2537            // Both bits ride along on the row's own `entry_state` join now. They
2538            // used to be membership tests against the full unread and starred
2539            // sets, which is why those two lists were fetched in their entirety
2540            // on every render even when the page showed a hundred rows.
2541            read: e.read,
2542            starred: e.starred,
2543            link: SafeLink::entry(e.id, &entry_scope_qs),
2544            cached: true,
2545            rkey: String::new(),
2546        })
2547        .collect();
2548
2549    // The uncached slice for this page follows the cached rows.
2550    let mut entries = entries;
2551    entries.extend(uncached_page);
2552    let entries = entries;
2553
2554    let selected_feed = q.feed.as_deref();
2555    let selected_folder = q.folder.as_deref();
2556
2557    // Build the shared sidebar (folders + loose feeds, with unread counts).
2558    let (folder_views, loose_feeds, _folder_options) =
2559        build_sidebar(&state, &did, &subs, selected_feed, selected_folder).await;
2560
2561    // Heading + scope query-string suffix.
2562    let (heading, scope_qs) = if let Some(feed_url) = selected_feed {
2563        let name = subs
2564            .iter()
2565            .find(|s| s.sub.url == feed_url)
2566            .map(|s| {
2567                display_title(
2568                    s.sub
2569                        .title
2570                        .as_deref()
2571                        .or(s.feed.as_ref().and_then(|f| f.title.as_deref())),
2572                    &s.sub.url,
2573                )
2574            })
2575            .unwrap_or_else(|| display_title(None, feed_url));
2576        (name, format!("feed={}", qenc(feed_url)))
2577    } else if let Some(folder_uri) = selected_folder {
2578        let name = folder_views
2579            .iter()
2580            .find(|f| f.uri == folder_uri)
2581            .map(|f| f.name.clone())
2582            .unwrap_or_else(|| "Folder".to_string());
2583        (name, format!("folder={}", qenc(folder_uri)))
2584    } else {
2585        let h = match view.as_str() {
2586            "all" => "All",
2587            "starred" => "Starred",
2588            _ => "Unread",
2589        };
2590        (h.to_string(), String::new())
2591    };
2592
2593    let feed_scope = selected_feed.map(str::to_string);
2594    let nav = build_nav(&user, &view, scope_qs, folder_views, loose_feeds, false);
2595
2596    // Pager links. `entry_scope_qs` already carries feed/folder/view, so the
2597    // page number is the only thing appended — which keeps a paged link
2598    // identical to an unpaged one in every other respect.
2599    let page_href = |n: i64| -> String {
2600        let mut parts = Vec::new();
2601        if !entry_scope_qs.is_empty() {
2602            parts.push(entry_scope_qs.clone());
2603        }
2604        if n > 1 {
2605            parts.push(format!("page={n}"));
2606        }
2607        if parts.is_empty() {
2608            "/".to_string()
2609        } else {
2610            format!("/?{}", parts.join("&"))
2611        }
2612    };
2613    let prev_href = (page > 1).then(|| page_href(page - 1));
2614    let next_href = (page * ENTRIES_PER_PAGE < total).then(|| page_href(page + 1));
2615
2616    let tmpl = IndexTemplate {
2617        card: Card::private(&state.config),
2618        version: VERSION,
2619        repo_url: REPO_URL,
2620        kofi_url: KOFI_URL,
2621        flash: q.flash.unwrap_or_default(),
2622        alert: alert.unwrap_or_default(),
2623        nav,
2624        entries,
2625        heading,
2626        feed_scope,
2627        total,
2628        // Whole-list figure, so it sits beside `total` without double counting.
2629        // The per-page slice is composed above and is not a heading number.
2630        uncached_total,
2631        page,
2632        page_count: page_count_for(total),
2633        prev_href,
2634        next_href,
2635    };
2636    Ok(render(&tmpl))
2637}
2638
2639/// Query for `GET /manage` — carries an optional flash after an action redirect.
2640#[derive(Debug, Deserialize, Default)]
2641struct ManageQuery {
2642    #[serde(default)]
2643    flash: Option<String>,
2644}
2645
2646/// `GET /manage` — the feed-management page. Renders the rail plus the subscribe
2647/// / your-feeds / OPML surfaces; the forms POST to the existing routes
2648/// (`/subscriptions`, `/folders`, `/opml`, …). A read/render route only — no
2649/// mutation logic of its own.
2650async fn manage(
2651    State(state): State<AppState>,
2652    headers: HeaderMap,
2653    Query(q): Query<ManageQuery>,
2654) -> Result<Response, WebError> {
2655    let user = match current_session(&state, &headers).await {
2656        Some(u) => u,
2657        None => return Ok(Redirect::to("/login").into_response()),
2658    };
2659    let did = user.did.clone();
2660
2661    let (subs, alert) = resolve_subscriptions_noting(&state, &did).await;
2662    let (folder_views, loose_feeds, folder_options) =
2663        build_sidebar(&state, &did, &subs, None, None).await;
2664
2665    // Clone the sidebar for the rail; the page body reuses folders/loose feeds.
2666    let nav = build_nav(
2667        &user,
2668        "unread",
2669        String::new(),
2670        folder_views.iter().map(clone_folder_view).collect(),
2671        loose_feeds.iter().map(clone_feed_view).collect(),
2672        true,
2673    );
2674
2675    let tmpl = ManageTemplate {
2676        card: Card::private(&state.config),
2677        version: VERSION,
2678        repo_url: REPO_URL,
2679        kofi_url: KOFI_URL,
2680        flash: q.flash.unwrap_or_default(),
2681        alert: alert.unwrap_or_default(),
2682        nav,
2683        folder_options,
2684        folders: folder_views,
2685        loose_feeds,
2686        standard_site: state.config.standard_site,
2687    };
2688    Ok(render(&tmpl))
2689}
2690
2691/// Shallow clone helpers so `/manage` can hand the same sidebar to both the rail
2692/// (`Nav`) and the page body without an extra DB round-trip.
2693fn clone_feed_view(f: &FeedView) -> FeedView {
2694    FeedView {
2695        rkey: f.rkey.clone(),
2696        url: f.url.clone(),
2697        title: f.title.clone(),
2698        unread: f.unread,
2699        selected: f.selected,
2700        folder: f.folder.clone(),
2701    }
2702}
2703
2704fn clone_folder_view(f: &FolderView) -> FolderView {
2705    FolderView {
2706        rkey: f.rkey.clone(),
2707        uri: f.uri.clone(),
2708        name: f.name.clone(),
2709        feeds: f.feeds.iter().map(clone_feed_view).collect(),
2710        selected: f.selected,
2711    }
2712}
2713
2714/// The set of feed URLs a scope covers: `Some([one url])` for a single-feed
2715/// scope, `Some([urls…])` for a folder (its member feeds), or `None` for the
2716/// unscoped "everything" view. A folder scope takes the feed scope when both are
2717/// somehow present (feed wins, matching the query precedence elsewhere).
2718fn scope_urls_for(
2719    subs: &[ResolvedSub],
2720    feed: Option<&str>,
2721    folder: Option<&str>,
2722) -> Option<Vec<String>> {
2723    if let Some(feed_url) = feed {
2724        Some(vec![feed_url.to_string()])
2725    } else {
2726        folder.map(|folder_uri| {
2727            subs.iter()
2728                .filter(|s| s.sub.folder.as_deref() == Some(folder_uri))
2729                .map(|s| s.sub.url.clone())
2730                .collect()
2731        })
2732    }
2733}
2734
2735/// The `at://` URI for a folder record given the owner DID + rkey.
2736fn folder_uri(did: &str, rkey: &str) -> String {
2737    format!("at://{did}/{}/{rkey}", lexicon::nsid::FOLDER)
2738}
2739
2740/// Build the sidebar folder/loose-feed views (with per-feed unread counts) for a
2741/// DID — the shared source for both the reader index and the rail on every
2742/// chrome page. `selected_feed` / `selected_folder` drive `aria-current`.
2743async fn build_sidebar(
2744    state: &AppState,
2745    did: &str,
2746    subs: &[ResolvedSub],
2747    selected_feed: Option<&str>,
2748    selected_folder: Option<&str>,
2749) -> (Vec<FolderView>, Vec<FeedView>, Vec<FolderOption>) {
2750    let pool = &state.db;
2751    // Counted in SQL. This used to fetch every unread ENTRY — article bodies and
2752    // all — purely to `.filter().count()` them in Rust, on every page that
2753    // renders chrome, which made the sidebar the most frequently executed
2754    // instance of the unbounded-projection problem.
2755    let unread_counts = store::unread_counts_by_feed(pool, did)
2756        .await
2757        .unwrap_or_else(|err| {
2758            warn!(%err, %did, "sidebar unread counts failed; rendering zeroes");
2759            Default::default()
2760        });
2761    let folders = state
2762        .repo()
2763        .list_folders_sorted(did)
2764        .await
2765        .unwrap_or_default();
2766
2767    let unread_count = |feed_id: Option<i64>| -> i64 {
2768        feed_id
2769            .and_then(|id| unread_counts.get(&id).copied())
2770            .unwrap_or(0)
2771    };
2772    let mk_feed_view = |s: &ResolvedSub| FeedView {
2773        rkey: s.rkey.clone(),
2774        url: s.sub.url.clone(),
2775        title: display_title(
2776            s.sub
2777                .title
2778                .as_deref()
2779                .or(s.feed.as_ref().and_then(|f| f.title.as_deref())),
2780            &s.sub.url,
2781        ),
2782        unread: unread_count(s.feed.as_ref().map(|f| f.id)),
2783        selected: selected_feed == Some(s.sub.url.as_str()),
2784        folder: s.sub.folder.clone(),
2785    };
2786
2787    let mut folder_views = Vec::with_capacity(folders.len());
2788    for (rkey, folder) in &folders {
2789        let uri = folder_uri(did, rkey);
2790        let feeds: Vec<FeedView> = subs
2791            .iter()
2792            .filter(|s| s.sub.folder.as_deref() == Some(uri.as_str()))
2793            .map(mk_feed_view)
2794            .collect();
2795        folder_views.push(FolderView {
2796            rkey: rkey.clone(),
2797            uri: uri.clone(),
2798            name: folder.name.clone(),
2799            feeds,
2800            selected: selected_folder == Some(uri.as_str()),
2801        });
2802    }
2803
2804    let known_uris: std::collections::HashSet<String> =
2805        folders.iter().map(|(r, _)| folder_uri(did, r)).collect();
2806    let loose_feeds: Vec<FeedView> = subs
2807        .iter()
2808        .filter(|s| {
2809            s.sub
2810                .folder
2811                .as_deref()
2812                .map(|f| !known_uris.contains(f))
2813                .unwrap_or(true)
2814        })
2815        .map(mk_feed_view)
2816        .collect();
2817
2818    let folder_options: Vec<FolderOption> = folders
2819        .iter()
2820        .map(|(rkey, folder)| FolderOption {
2821            name: folder.name.clone(),
2822            uri: folder_uri(did, rkey),
2823        })
2824        .collect();
2825
2826    (folder_views, loose_feeds, folder_options)
2827}
2828
2829/// Assemble the shared rail [`Nav`] for a chrome page.
2830fn build_nav(
2831    user: &CurrentUser,
2832    view: &str,
2833    scope_qs: String,
2834    folders: Vec<FolderView>,
2835    loose_feeds: Vec<FeedView>,
2836    manage_active: bool,
2837) -> Nav {
2838    Nav {
2839        handle: display_handle(user.handle.as_deref(), &user.did),
2840        avatar: avatar_initials(user.handle.as_deref(), &user.did),
2841        view: view.to_string(),
2842        scope_qs,
2843        folders,
2844        loose_feeds,
2845        manage_active,
2846    }
2847}
2848
2849// ---------------------------------------------------------------------------
2850// Reader: single entry
2851// ---------------------------------------------------------------------------
2852
2853/// Query for `GET /entries/:id` — carries the reading context (scope + view) so
2854/// prev/next and "back" stay within the list the reader came from.
2855#[derive(Debug, Deserialize, Default)]
2856struct EntryQuery {
2857    #[serde(default)]
2858    feed: Option<String>,
2859    #[serde(default)]
2860    folder: Option<String>,
2861    #[serde(default)]
2862    view: Option<String>,
2863}
2864
2865/// `GET /entries/:id` — the clean reader view for one entry, with prev/next
2866/// within the current reading list.
2867async fn entry_view(
2868    State(state): State<AppState>,
2869    headers: HeaderMap,
2870    Path(id): Path<i64>,
2871    Query(q): Query<EntryQuery>,
2872) -> Result<Response, WebError> {
2873    let user = match current_session(&state, &headers).await {
2874        Some(u) => u,
2875        None => return Ok(Redirect::to("/login").into_response()),
2876    };
2877    let did = user.did.clone();
2878    let pool = &state.db;
2879
2880    // Resolve subscriptions FIRST: this refreshes the `sub_ref` projection so
2881    // the per-DID entry gate below authorizes against the caller's current PDS
2882    // subscription set (not another user's cached feeds).
2883    let subs = resolve_subscriptions(&state, &did).await;
2884
2885    let entry = match get_entry_by_id(pool, &did, id).await? {
2886        Some(e) => e,
2887        None => return Ok((StatusCode::NOT_FOUND, "entry not found").into_response()),
2888    };
2889
2890    let feed_title = feed_title_by_entry(pool, entry.feed_id).await;
2891
2892    let read = entry_is_read(pool, &did, id).await?;
2893    let starred = entry_is_starred(pool, &did, id).await?;
2894
2895    // Reconstruct the current list to compute prev/next, so paging in the reader
2896    // matches what the list showed.
2897    let (prev_id, next_id) = neighbors_in_scope(&state, &did, &q, id).await;
2898
2899    let back_qs = scope_query(&q);
2900
2901    let (folder_views, loose_feeds, _) =
2902        build_sidebar(&state, &did, &subs, q.feed.as_deref(), q.folder.as_deref()).await;
2903    let nav_view = match q.view.as_deref() {
2904        Some("all") => "all",
2905        Some("starred") => "starred",
2906        _ => "unread",
2907    };
2908    let nav = build_nav(
2909        &user,
2910        nav_view,
2911        back_qs.clone(),
2912        folder_views,
2913        loose_feeds,
2914        false,
2915    );
2916
2917    let tmpl = EntryTemplate {
2918        card: Card::private(&state.config),
2919        version: VERSION,
2920        repo_url: REPO_URL,
2921        kofi_url: KOFI_URL,
2922        nav,
2923        id: entry.id,
2924        title: entry
2925            .title
2926            .clone()
2927            .filter(|t| !t.trim().is_empty())
2928            .unwrap_or_else(|| "(untitled)".to_string()),
2929        feed_title,
2930        author: entry.author.clone().filter(|a| !a.trim().is_empty()),
2931        published: display_date(entry.published.as_deref()),
2932        url: entry.url.as_deref().and_then(SafeLink::external_opt),
2933        content_html: entry.content_html.clone(),
2934        read,
2935        starred,
2936        back_qs,
2937        prev_id,
2938        next_id,
2939        oob: false,
2940    };
2941    Ok(render(&tmpl))
2942}
2943
2944/// Compute the prev/next entry ids around `current` within the reader's current
2945/// scope + view, so the reader view can offer keyboard/paging navigation.
2946async fn neighbors_in_scope(
2947    state: &AppState,
2948    did: &str,
2949    q: &EntryQuery,
2950    current: i64,
2951) -> (Option<i64>, Option<i64>) {
2952    let idx_q = IndexQuery {
2953        feed: q.feed.clone(),
2954        folder: q.folder.clone(),
2955        view: q.view.clone(),
2956        // Neighbours span the whole list, not the page the reader arrived from.
2957        page: None,
2958        flash: None,
2959    };
2960    let ids = list_entry_ids(state, did, &idx_q).await;
2961    let pos = ids.iter().position(|&x| x == current);
2962    match pos {
2963        Some(p) => {
2964            let prev = if p > 0 { Some(ids[p - 1]) } else { None };
2965            let next = ids.get(p + 1).copied();
2966            (prev, next)
2967        }
2968        None => (None, None),
2969    }
2970}
2971
2972/// The ordered entry ids for a scope + view — the same ordering `index` renders,
2973/// used for reader prev/next. Best-effort; PDS failures degrade to local cache.
2974async fn list_entry_ids(state: &AppState, did: &str, q: &IndexQuery) -> Vec<i64> {
2975    let pool = &state.db;
2976    let subs = resolve_subscriptions(state, did).await;
2977
2978    let scope_urls = scope_urls_for(&subs, q.feed.as_deref(), q.folder.as_deref());
2979
2980    // Ids only, and bounded. This used to fetch whole entries — bodies included
2981    // — for all three views and then throw everything but `id` away; the "all"
2982    // branch additionally ran one unbounded query PER FEED and sorted the union
2983    // in memory. Scope is now a feed-id restriction inside the query, so the
2984    // database does the filtering and the ordering exactly once.
2985    store::list_entry_ids(
2986        pool,
2987        did,
2988        list_view_of(q.view.as_deref()),
2989        scoped_feed_ids(&subs, &scope_urls).as_deref(),
2990        PREV_NEXT_MAX,
2991    )
2992    .await
2993    .unwrap_or_else(|err| {
2994        warn!(%err, %did, "prev/next id list failed; the reader loses its neighbour links");
2995        Vec::new()
2996    })
2997}
2998
2999/// Map the `?view=` query value onto the store's list view. Anything
3000/// unrecognised is the unread default, matching `index`.
3001fn list_view_of(view: Option<&str>) -> store::ListView {
3002    match view {
3003        Some("all") => store::ListView::All,
3004        Some("starred") => store::ListView::Starred,
3005        _ => store::ListView::Unread,
3006    }
3007}
3008
3009/// Translate a feed/folder scope into the feed ids to restrict a list query to.
3010///
3011/// `None` means unscoped (every subscribed feed). `Some(&[])` means the scope
3012/// matched no local feed, which must return nothing rather than everything — so
3013/// the empty vec is deliberately preserved, not collapsed back into `None`.
3014fn scoped_feed_ids(subs: &[ResolvedSub], scope_urls: &Option<Vec<String>>) -> Option<Vec<i64>> {
3015    let urls = scope_urls.as_ref()?;
3016    Some(
3017        subs.iter()
3018            .filter(|s| urls.contains(&s.sub.url))
3019            .filter_map(|s| s.feed.as_ref().map(|f| f.id))
3020            .collect(),
3021    )
3022}
3023
3024/// Build a `?…` query string that preserves the reading scope + view for links.
3025fn scope_query(q: &EntryQuery) -> String {
3026    let mut parts = Vec::new();
3027    if let Some(f) = q.feed.as_deref() {
3028        parts.push(format!("feed={}", qenc(f)));
3029    }
3030    if let Some(f) = q.folder.as_deref() {
3031        parts.push(format!("folder={}", qenc(f)));
3032    }
3033    if let Some(v) = q.view.as_deref() {
3034        if v != "unread" {
3035            parts.push(format!("view={}", qenc(v)));
3036        }
3037    }
3038    parts.join("&")
3039}
3040
3041// ---------------------------------------------------------------------------
3042// Mark read / unread
3043// ---------------------------------------------------------------------------
3044
3045/// Form body for `POST /entries/:id/read`.
3046#[derive(Debug, Deserialize)]
3047struct ReadForm {
3048    #[serde(default)]
3049    read: Option<String>,
3050}
3051
3052/// `POST /entries/:id/read` — toggle an entry's read-state for the current DID.
3053async fn mark_read(
3054    State(state): State<AppState>,
3055    Path(id): Path<i64>,
3056    headers: HeaderMap,
3057    Form(form): Form<ReadForm>,
3058) -> Result<Response, WebError> {
3059    let did = match current_did(&state, &headers).await {
3060        Some(d) => d,
3061        None => return Ok(Redirect::to("/login").into_response()),
3062    };
3063    let pool = &state.db;
3064
3065    let read = matches!(
3066        form.read.as_deref(),
3067        Some("true") | Some("1") | Some("on") | None
3068    );
3069
3070    // Refresh the caller's `sub_ref` projection, then apply the AUTHORIZED
3071    // mutation: `mark_read` only writes when `did` subscribes to the entry's
3072    // feed. A non-subscriber gets a 404, never a mutation of someone else's
3073    // (or the shared cache's) state.
3074    resolve_subscriptions(&state, &did).await;
3075    if !store::mark_read(pool, &did, id, read).await? {
3076        return Ok((StatusCode::NOT_FOUND, "entry not found").into_response());
3077    }
3078
3079    if !is_htmx(&headers) {
3080        return Ok(Redirect::to("/").into_response());
3081    }
3082
3083    // The reader view swaps an out-of-band action-bar fragment (its `<li>` isn't
3084    // in the DOM), so its button's hidden value + aria-pressed update in place
3085    // and a second keypress can reverse the toggle. The list view swaps the row.
3086    if is_reader_request(&headers) {
3087        let starred = entry_is_starred(pool, &did, id).await?;
3088        return Ok(render(&EntryActionBarTemplate {
3089            id,
3090            read,
3091            starred,
3092            oob: true,
3093        }));
3094    }
3095
3096    let row = build_entry_row(pool, &did, id, Some(read)).await?;
3097    match row {
3098        Some(r) => Ok(render(&EntryRowTemplate { e: r })),
3099        None => Ok((StatusCode::NOT_FOUND, "entry not found").into_response()),
3100    }
3101}
3102
3103// ---------------------------------------------------------------------------
3104// Star / save
3105// ---------------------------------------------------------------------------
3106
3107/// Form body for `POST /entries/:id/star`.
3108#[derive(Debug, Deserialize)]
3109struct StarForm {
3110    #[serde(default)]
3111    starred: Option<String>,
3112}
3113
3114/// `POST /entries/:id/star` — star/unstar an entry.
3115///
3116/// Sets the local `starred` bit (fast working copy) and writes/removes a
3117/// `community.lexicon.rss.saved` record in the user's PDS (stars are worth
3118/// owning). The PDS write is best-effort — the local star still lands.
3119async fn toggle_star(
3120    State(state): State<AppState>,
3121    Path(id): Path<i64>,
3122    headers: HeaderMap,
3123    Form(form): Form<StarForm>,
3124) -> Result<Response, WebError> {
3125    let did = match current_did(&state, &headers).await {
3126        Some(d) => d,
3127        None => return Ok(Redirect::to("/login").into_response()),
3128    };
3129    let pool = &state.db;
3130
3131    let starred = matches!(
3132        form.starred.as_deref(),
3133        Some("true") | Some("1") | Some("on") | None
3134    );
3135
3136    // Refresh the caller's `sub_ref` projection, then apply the AUTHORIZED
3137    // mutation: `mark_starred` only writes when `did` subscribes to the entry's
3138    // feed. A non-subscriber gets a 404, never a mutation.
3139    resolve_subscriptions(&state, &did).await;
3140    if !store::mark_starred(pool, &did, id, starred).await? {
3141        return Ok((StatusCode::NOT_FOUND, "entry not found").into_response());
3142    }
3143
3144    // Reflect into the PDS saved-records collection. `get_entry_by_id` is scoped
3145    // to the caller's subscriptions, so this only ever acts on the caller's feed.
3146    if let Ok(Some(entry)) = get_entry_by_id(pool, &did, id).await {
3147        let entry_url = entry.url.clone().unwrap_or_default();
3148        if !entry_url.is_empty() {
3149            if starred {
3150                let mut saved = Saved::new(entry_url.clone(), now_rfc3339());
3151                saved.title = entry.title.clone();
3152                saved.feed_url = feed_url_for_id(pool, entry.feed_id).await;
3153                saved.entry_id = Some(entry.guid.clone());
3154                match state.repo().add_saved(&did, &saved).await {
3155                    Ok(rkey) => info!(%did, url = %entry_url, %rkey, "wrote saved record to PDS"),
3156                    Err(err) => warn!(%err, %did, "PDS saved write failed (starred locally)"),
3157                }
3158            } else {
3159                // Un-star: find and delete the matching saved record by URL.
3160                match state.repo().list_saved(&did).await {
3161                    Ok(records) => {
3162                        for (rkey, _rec) in records.iter().filter(|(_, r)| r.url == entry_url) {
3163                            if let Err(err) = state.repo().remove_saved(&did, rkey).await {
3164                                warn!(%err, %did, %rkey, "PDS saved delete failed");
3165                            }
3166                        }
3167                    }
3168                    Err(err) => warn!(%err, %did, "could not list saved records to un-star"),
3169                }
3170            }
3171        }
3172    }
3173
3174    if !is_htmx(&headers) {
3175        return Ok(Redirect::to("/").into_response());
3176    }
3177
3178    // Reader → out-of-band action-bar fragment; list → the row (see mark_read).
3179    if is_reader_request(&headers) {
3180        let read = entry_is_read(pool, &did, id).await?;
3181        return Ok(render(&EntryActionBarTemplate {
3182            id,
3183            read,
3184            starred,
3185            oob: true,
3186        }));
3187    }
3188
3189    let row = build_entry_row(pool, &did, id, None).await?;
3190    match row {
3191        Some(r) => Ok(render(&EntryRowTemplate { e: r })),
3192        None => Ok((StatusCode::NOT_FOUND, "entry not found").into_response()),
3193    }
3194}
3195
3196/// The feed URL for a cached feed id, if the row exists.
3197async fn feed_url_for_id(pool: &store::Pool, feed_id: i64) -> Option<String> {
3198    sqlx::query_scalar::<_, String>("SELECT url FROM feeds WHERE id = ?1")
3199        .bind(feed_id)
3200        .fetch_optional(pool)
3201        .await
3202        .ok()
3203        .flatten()
3204}
3205
3206// ---------------------------------------------------------------------------
3207// Mark-all-read
3208// ---------------------------------------------------------------------------
3209
3210/// Query for `POST /read-all` — an optional `?feed=<url>` scopes it to one feed;
3211/// absent means mark everything read.
3212#[derive(Debug, Deserialize, Default)]
3213struct ReadAllQuery {
3214    #[serde(default)]
3215    feed: Option<String>,
3216}
3217
3218/// `POST /read-all` — mark every entry read for the current DID, optionally
3219/// scoped to one feed (mark-all-read per feed or globally).
3220async fn mark_all_read(
3221    State(state): State<AppState>,
3222    headers: HeaderMap,
3223    Query(q): Query<ReadAllQuery>,
3224) -> Result<Response, WebError> {
3225    let did = match current_did(&state, &headers).await {
3226        Some(d) => d,
3227        None => return Ok(Redirect::to("/login").into_response()),
3228    };
3229    let pool = &state.db;
3230
3231    // Refresh the caller's `sub_ref` projection so the scoped mark-read writes
3232    // only ever touch feeds this DID actually subscribes to.
3233    resolve_subscriptions(&state, &did).await;
3234
3235    if let Some(feed_url) = q.feed.as_deref() {
3236        if let Ok(Some(feed)) = store::get_feed_by_url(pool, feed_url).await {
3237            store::mark_feed_read(pool, &did, feed.id, true).await?;
3238        }
3239        return Ok(Redirect::to(&format!("/?feed={}", qenc(feed_url))).into_response());
3240    }
3241
3242    // Global: mark every subscribed feed read. Fan out over the DID's feeds
3243    // (bounded by the per-DID subscription cap) using the batched per-feed path,
3244    // rather than one UPDATE round-trip per unread entry (unbounded) — same end
3245    // state, but O(feeds) statements instead of O(unread entries).
3246    for feed_id in store::subscribed_feed_ids(pool, &did).await? {
3247        store::mark_feed_read(pool, &did, feed_id, true).await?;
3248    }
3249    Ok(Redirect::to("/").into_response())
3250}
3251
3252// ---------------------------------------------------------------------------
3253// Subscribe by URL
3254// ---------------------------------------------------------------------------
3255
3256/// Flash for a URL this instance cannot store as a feed — not private, just
3257/// not a kind of feed it supports (an `at://` publication with
3258/// `FEATHERREADER_STANDARD_SITE` off, an unsupported scheme). Distinct from
3259/// [`PRIVATE_FEED_REFUSAL`], whose "not saved or sent anywhere" would be a
3260/// false promise for a record that may already exist in the user's PDS.
3261const UNSUPPORTED_FEED_URL_REFUSAL: &str =
3262    "That isn't a kind of feed this instance can subscribe to. Nothing was saved.";
3263
3264/// Shown when an OPML export is refused because the subscription list could not
3265/// be read in full.
3266///
3267/// **An empty export is worse than no export.** This path used to
3268/// `unwrap_or_default()`, so a failed read produced a 200 carrying a zero-feed
3269/// file — a blank backup, handed over at the moment the reader reached for one.
3270const EXPORT_INCOMPLETE_REFUSAL: &str =
3271    "Could not read your subscriptions in full, so nothing was exported. Your \
3272     feeds are unchanged — try again, and if it keeps failing the list may be \
3273     larger than this reader can page through.";
3274
3275/// Refusal message shown when a private/paid feed is submitted. FeatherReader
3276/// stores subscriptions in the user's PUBLIC PDS, so it supports public feeds
3277/// only for now — a private feed's secret URL is never saved, fetched, or sent
3278/// anywhere. Kept as a constant so the add and OPML paths share the exact wording
3279/// and the boot-smoke can assert on it.
3280const PRIVATE_FEED_REFUSAL: &str = "Private/paid feeds aren't supported yet. \
3281    FeatherReader stores your subscriptions in your public PDS, so it supports public \
3282    feeds for now — private-feed support arrives when atproto's private data \
3283    (permissioned records) ships. Your feed URL was not saved or sent anywhere.";
3284
3285/// Form body for `POST /subscriptions`.
3286#[derive(Debug, Deserialize)]
3287struct SubscribeForm {
3288    url: String,
3289    /// Optional folder `at://` URI to file the new feed under.
3290    #[serde(default)]
3291    folder: Option<String>,
3292}
3293
3294/// The DID-form URL to store for a pasted `at://` publication, or the flash
3295/// to refuse it with.
3296///
3297/// - The scheme is canonicalised: `At://` is the same publication, and
3298///   storing a second spelling makes a second row for it (#183).
3299/// - It must name a `site.standard.publication`; anything else is not a feed
3300///   this instance can read.
3301/// - A handle is resolved to its DID: a handle is a mutable name, and
3302///   `feeds.url` is keyed on identity, so only the DID form is stored.
3303async fn publication_url_from_paste(state: &AppState, input: &str) -> Result<String, String> {
3304    let unsupported = || UNSUPPORTED_FEED_URL_REFUSAL.to_string();
3305    let canonical = format!(
3306        "{}{}",
3307        crate::atproto::AT_URI_PREFIX,
3308        &input[crate::atproto::AT_URI_PREFIX.len()..]
3309    );
3310    let uri = crate::standard_site::AtUri::parse(&canonical).ok_or_else(unsupported)?;
3311    if uri.collection != lexicon::nsid::STANDARD_PUBLICATION {
3312        return Err(unsupported());
3313    }
3314    let did = if crate::oauth::identity::is_atproto_did(&uri.authority) {
3315        uri.authority.clone()
3316    } else {
3317        let handle =
3318            // Validated as a handle before it is sent anywhere: an authority
3319            // that is neither a DID nor a handle (`did:plc:TOOSHORT`, an
3320            // uppercase DID, a newline) is unsupported, not a lookup (found in
3321            // review).
3322            crate::oauth::identity::normalize_handle(&uri.authority).map_err(|_| unsupported())?;
3323        crate::atproto::resolve_handle(&state.http, &state.config.resolver_base, &handle)
3324            .await
3325            .map_err(|err| {
3326                warn!(%err, handle = %uri.authority, "could not resolve a pasted publication's handle");
3327                format!("Couldn't resolve the handle {} to an account.", uri.authority)
3328            })?
3329    };
3330    let url = format!(
3331        "{}{did}/{}/{}",
3332        crate::atproto::AT_URI_PREFIX,
3333        uri.collection,
3334        uri.rkey
3335    );
3336    if !feed::is_storable_feed_url(&url, true) {
3337        return Err(unsupported());
3338    }
3339    Ok(url)
3340}
3341
3342/// `POST /subscriptions` — subscribe by URL.
3343async fn add_subscription(
3344    State(state): State<AppState>,
3345    headers: HeaderMap,
3346    Form(form): Form<SubscribeForm>,
3347) -> Result<Response, WebError> {
3348    let did = match current_did(&state, &headers).await {
3349        Some(d) => d,
3350        None => return Ok(Redirect::to("/login").into_response()),
3351    };
3352    let pool = &state.db;
3353    let input = form.url.trim().to_string();
3354    if input.is_empty() {
3355        return Ok(Redirect::to("/").into_response());
3356    }
3357
3358    // Per-DID subscription cap: bound one account's storage/poller footprint on
3359    // the small box. Checked BEFORE any fetch/resolve so an over-cap account
3360    // can't even trigger an outbound request. `<= 0` disables the cap.
3361    let cap = state.config.max_subs_per_did;
3362    if cap > 0 {
3363        match store::count_subscriptions_for_did(pool, &did).await {
3364            Ok(n) if n >= cap => {
3365                info!(%did, current = n, cap, "refused subscribe: per-DID subscription cap reached");
3366                return Ok(Redirect::to(&format!(
3367                    "/?flash={}",
3368                    qenc(&format!(
3369                        "Subscription limit reached ({cap}). Remove a feed before adding another."
3370                    ))
3371                ))
3372                .into_response());
3373            }
3374            Ok(_) => {}
3375            Err(err) => warn!(%err, %did, "could not count subscriptions for cap check; allowing"),
3376        }
3377    }
3378
3379    // **An at:// paste is a standard.site publication, read by the poller
3380    // since 0.4.0**: canonicalised and resolved to its DID form here, then it
3381    // joins the ordinary path below. With the flag off it is refused as it
3382    // always was — the flag gates what may be stored.
3383    let is_at_uri = input
3384        .get(..crate::atproto::AT_URI_PREFIX.len())
3385        .is_some_and(|p| p.eq_ignore_ascii_case(crate::atproto::AT_URI_PREFIX));
3386    let publication_url = if is_at_uri {
3387        if !state.config.standard_site {
3388            info!(url = %input, %did, "refused an at:// paste: standard.site is off (not stored)");
3389            return Ok(
3390                Redirect::to(&format!("/?flash={}", qenc(UNSUPPORTED_FEED_URL_REFUSAL)))
3391                    .into_response(),
3392            );
3393        }
3394        match publication_url_from_paste(&state, &input).await {
3395            Ok(url) => Some(url),
3396            Err(flash) => {
3397                info!(url = %input, %did, %flash, "refused an at:// paste (not stored)");
3398                return Ok(Redirect::to(&format!("/?flash={}", qenc(&flash))).into_response());
3399            }
3400        }
3401    } else {
3402        None
3403    };
3404
3405    if let feed::FeedPrivacy::Private(reason) =
3406        feed::classify_feed_privacy(publication_url.as_deref().unwrap_or(&input))
3407    {
3408        info!(url = %input, %reason, %did, "refused private/paid feed at add (not fetched or stored)");
3409        return Ok(
3410            Redirect::to(&format!("/?flash={}", qenc(PRIVATE_FEED_REFUSAL))).into_response(),
3411        );
3412    }
3413
3414    let resolved = match publication_url {
3415        Some(url) => Ok(url),
3416        None => resolve_feed_url(&state.config, &input).await,
3417    };
3418    let feed_url = match resolved {
3419        Ok(u) => u,
3420        Err(err) => {
3421            warn!(%err, url = %input, "could not resolve a feed from the given URL");
3422            return Ok(Redirect::to(&format!(
3423                "/?flash={}",
3424                qenc("Couldn't find a feed at that URL")
3425            ))
3426            .into_response());
3427        }
3428    };
3429
3430    // Defensive: resolution may have discovered a feed URL that itself carries a
3431    // secret (e.g. a public site page linking a tokened feed). Re-check the
3432    // resolved URL and refuse before storing/writing anything.
3433    if let feed::FeedPrivacy::Private(reason) = feed::classify_feed_privacy(&feed_url) {
3434        info!(url = %feed_url, %reason, %did, "refused private/paid feed after resolution (not stored)");
3435        return Ok(
3436            Redirect::to(&format!("/?flash={}", qenc(PRIVATE_FEED_REFUSAL))).into_response(),
3437        );
3438    }
3439
3440    // The URL about to be STORED is what must be storable — not the one the
3441    // user typed. Autodiscovery already yields only http(s), but this is the
3442    // path that writes the row and the PDS record, so the check lives here too:
3443    // the same gate the OPML and rename paths apply, on the same terms.
3444    if !feed::is_storable_feed_url(&feed_url, state.config.standard_site) {
3445        info!(url = %feed_url, %did, "refused unsupported feed URL after resolution (not stored)");
3446        return Ok(
3447            Redirect::to(&format!("/?flash={}", qenc(UNSUPPORTED_FEED_URL_REFUSAL)))
3448                .into_response(),
3449        );
3450    }
3451
3452    // Global feeds ceiling: a brand-new distinct feed is refused once the shared
3453    // cache is full (an existing/duplicate feed URL is always fine — it adds no
3454    // row). Bounds total cache size across all users on the box. `<= 0` disables.
3455    let feeds_cap = state.config.max_feeds_global;
3456    if feeds_cap > 0 && store::get_feed_by_url(pool, &feed_url).await?.is_none() {
3457        match store::count_feeds(pool).await {
3458            Ok(n) if n >= feeds_cap => {
3459                warn!(%did, feeds = n, cap = feeds_cap, feed = %feed_url, "refused subscribe: global feeds ceiling reached");
3460                return Ok(Redirect::to(&format!(
3461                    "/?flash={}",
3462                    qenc(
3463                        "This instance is at its feed capacity right now. Please try again later."
3464                    )
3465                ))
3466                .into_response());
3467            }
3468            Ok(_) => {}
3469            Err(err) => warn!(%err, "could not count feeds for global-cap check; allowing"),
3470        }
3471    }
3472
3473    store::upsert_feed(
3474        pool,
3475        &store::NewFeed {
3476            url: feed_url.clone(),
3477            ..Default::default()
3478        },
3479    )
3480    .await?;
3481
3482    if let Ok(client) = feed::build_client() {
3483        if let Some(feed_row) = store::get_feed_by_url(pool, &feed_url).await? {
3484            match feed::poll_feed_by_kind(pool, &client, &state.config, &feed_row).await {
3485                Ok(outcome) => {
3486                    info!(feed = %feed_url, ?outcome, "polled new subscription");
3487                    // **This path is not the scheduler, so it must settle the
3488                    // error columns itself.** `poll_feed` writes validators and
3489                    // `last_polled` and nothing else.
3490                    feed::settle_poll(pool, &feed_url, &outcome, state.config.poll_interval).await;
3491                }
3492                Err(err) => warn!(%err, feed = %feed_url, "initial poll failed"),
3493            }
3494        }
3495    }
3496
3497    let mut sub = Subscription::new(feed_url.clone(), now_rfc3339());
3498    if let Ok(Some(feed_row)) = store::get_feed_by_url(pool, &feed_url).await {
3499        sub.title = feed_row.title.clone();
3500        sub.site_url = feed_row.site_url.clone();
3501    }
3502    sub.folder = form
3503        .folder
3504        .map(|f| f.trim().to_string())
3505        .filter(|f| !f.is_empty());
3506
3507    match state.repo().add_subscription(&did, &sub).await {
3508        Ok(rkey) => info!(feed = %feed_url, %rkey, %did, "wrote subscription record to PDS"),
3509        Err(err) => {
3510            warn!(%err, feed = %feed_url, %did, "PDS subscription write failed (cached locally)")
3511        }
3512    }
3513
3514    Ok(Redirect::to("/").into_response())
3515}
3516
3517/// `POST /subscriptions/:rkey/delete` — unsubscribe (delete the PDS record).
3518async fn delete_subscription(
3519    State(state): State<AppState>,
3520    headers: HeaderMap,
3521    Path(rkey): Path<String>,
3522) -> Result<Response, WebError> {
3523    let did = match current_did(&state, &headers).await {
3524        Some(d) => d,
3525        None => return Ok(Redirect::to("/login").into_response()),
3526    };
3527    match state.repo().remove_subscription(&did, &rkey).await {
3528        Ok(()) => info!(%did, %rkey, "unsubscribed (deleted PDS subscription record)"),
3529        Err(err) => warn!(%err, %did, %rkey, "PDS unsubscribe failed"),
3530    }
3531    Ok(Redirect::to("/").into_response())
3532}
3533
3534/// Form body for `POST /subscriptions/:rkey/rename`.
3535#[derive(Debug, Deserialize)]
3536struct RenameSubForm {
3537    url: String,
3538    #[serde(default)]
3539    title: Option<String>,
3540    #[serde(default)]
3541    site_url: Option<String>,
3542    #[serde(default)]
3543    folder: Option<String>,
3544}
3545
3546/// `POST /subscriptions/:rkey/rename` — retitle a feed and/or move it to a
3547/// folder, rewriting the whole subscription record via `putRecord`.
3548async fn rename_subscription(
3549    State(state): State<AppState>,
3550    headers: HeaderMap,
3551    Path(rkey): Path<String>,
3552    Form(form): Form<RenameSubForm>,
3553) -> Result<Response, WebError> {
3554    let did = match current_did(&state, &headers).await {
3555        Some(d) => d,
3556        None => return Ok(Redirect::to("/login").into_response()),
3557    };
3558    let feed_url = form.url.trim().to_string();
3559
3560    // Reject an empty/blank resolved URL — a rename with no usable URL must not
3561    // write a junk row to the cache or a malformed subscription record to the
3562    // PDS (add_subscription refuses an empty input the same way).
3563    if feed_url.is_empty() {
3564        return Ok(Redirect::to("/").into_response());
3565    }
3566
3567    // **Read before write — `update_subscription` is a `putRecord`, and a
3568    // putRecord replaces the WHOLE record** (see its doc on `atproto.rs`).
3569    //
3570    // This used to build a fresh `Subscription::new(feed_url, now_rfc3339())`
3571    // and hand that over, so every field the form does not carry was written
3572    // back as its default. `templates/manage_row.html` posts `url`, `title` and
3573    // `folder` — and nothing else — so a rename silently destroyed four fields:
3574    // `siteUrl`, `fetchHint`, `private`, and `createdAt`.
3575    //
3576    // `createdAt` is the one that matters most: it is the reader's subscribe
3577    // time, it is the sort key for "when did I subscribe", it lives in THEIR
3578    // repo rather than our cache, and once overwritten it is gone with nothing
3579    // in the UI to say so.
3580    //
3581    // There is no single-record read on `Repo` (no `getRecord`), so this lists
3582    // and filters. That is one extra round trip on an action that is already
3583    // doing a PDS write, and it is bounded; a `get_subscription` would be
3584    // strictly better if this ever measures badly.
3585    //
3586    // **A failed read refuses the rename.** Falling back to the old
3587    // rebuild-from-scratch here would reinstate the data loss on exactly the
3588    // flaky path, which is the worst place to have it. The write below already
3589    // takes this stance — "a failure here means nothing was renamed or moved" —
3590    // and the read gets the same one.
3591    let existing = match state.repo().list_subscriptions_sorted(&did).await {
3592        Ok(subs) => subs.into_iter().find(|(k, _)| *k == rkey).map(|(_, s)| s),
3593        Err(err) => {
3594            warn!(%err, %did, %rkey, "could not read the subscription before renaming it");
3595            return Ok(Redirect::to(&format!(
3596                "/?flash={}",
3597                qenc("Could not reach your PDS — nothing was renamed or moved.")
3598            ))
3599            .into_response());
3600        }
3601    };
3602    let Some(existing) = existing else {
3603        // The rkey is not in the reader's repo. Renaming a record that is not
3604        // there would CREATE one, which is not what "rename" means and would
3605        // give it a fresh `createdAt` — the bug this read exists to prevent.
3606        warn!(%did, %rkey, "refused rename: no such subscription in the repo");
3607        return Ok(Redirect::to(&format!(
3608            "/?flash={}",
3609            qenc("That subscription is no longer in your repo — nothing was renamed or moved.")
3610        ))
3611        .into_response());
3612    };
3613
3614    // The subscription can be repointed at a different feed URL. **Every gate
3615    // on the URL applies to a repoint and only a repoint** — the three below
3616    // were each, at one time, run before this line on the URL as posted, and
3617    // each refused a pure retitle of a record that already existed:
3618    //
3619    // - privacy: the narrowed at:// arm fails closed as `Private` for an
3620    //   at-URI that is not a publication (a feed generator another client
3621    //   subscribed to), so the record became un-editable with a flash saying
3622    //   it "was not saved or sent anywhere";
3623    // - the global feeds ceiling keyed on "URL not in the cache", and an
3624    //   at:// record is never cached with the flag off, so at capacity a
3625    //   retitle was refused for a row the handler would not insert;
3626    // - storability, the same way.
3627    //
3628    // An unchanged URL is already in the reader's repo; refusing to retitle
3629    // it protects nothing and takes their own record away from them.
3630    // Like for like: the form value is trimmed, and a record another client
3631    // wrote may carry padding — compared raw, every retitle of it was a repoint.
3632    let url_changed = existing.url.trim() != feed_url;
3633
3634    // **Storability, on the same terms as the add and OPML paths — for a
3635    // REPOINT, and FIRST.** A target this instance cannot store gets that
3636    // answer, not "private" (the at:// arm fails closed) or "at capacity"
3637    // (it would never be inserted) — the ordering the add path has. This handler writes `feeds` via `upsert_feed` and had only the
3638    // privacy check above, so `FEATHERREADER_STANDARD_SITE` was bypassable
3639    // here; a review found it by enumerating every writer of the table. The
3640    // first fix ran this check before the repo lookup, on the URL as posted —
3641    // which refused a pure retitle of a subscription that already IS an
3642    // at-URI, on every instance with the flag off. The flag gates what the
3643    // cache may store, not whether a reader may edit their own record: an
3644    // unchanged non-storable URL keeps its PDS write and simply gets no cache
3645    // row below.
3646    let storable = feed::is_storable_feed_url(&feed_url, state.config.standard_site);
3647    if url_changed && !storable {
3648        info!(url = %feed_url, %did, %rkey, "refused a repoint to a non-storable feed URL");
3649        return Ok(
3650            Redirect::to(&format!("/?flash={}", qenc(UNSUPPORTED_FEED_URL_REFUSAL)))
3651                .into_response(),
3652        );
3653    }
3654
3655    // Block private/paid feeds on a repoint. `url` is attacker-controllable,
3656    // and rename both upserts it to the local cache AND rewrites the PDS
3657    // subscription record (a public `putRecord`), so without this guard a
3658    // crafted rename could land a secret-bearing URL in the public PDS — the
3659    // exact leak the add and OPML paths already prevent.
3660    if url_changed {
3661        if let feed::FeedPrivacy::Private(reason) = feed::classify_feed_privacy(&feed_url) {
3662            info!(url = %feed_url, %reason, %did, %rkey, "refused private/paid feed at rename (not stored or written)");
3663            return Ok(
3664                Redirect::to(&format!("/?flash={}", qenc(PRIVATE_FEED_REFUSAL))).into_response(),
3665            );
3666        }
3667    }
3668
3669    // Global feeds ceiling parity with add_subscription: a repoint to a
3670    // brand-new feed URL would insert a NEW `feeds` row. Refuse that when the
3671    // shared cache is at capacity (an existing/duplicate URL adds no row and
3672    // is always fine). `<= 0` disables.
3673    let feeds_cap = state.config.max_feeds_global;
3674    if url_changed
3675        && feeds_cap > 0
3676        && store::get_feed_by_url(&state.db, &feed_url)
3677            .await?
3678            .is_none()
3679    {
3680        match store::count_feeds(&state.db).await {
3681            Ok(n) if n >= feeds_cap => {
3682                warn!(%did, %rkey, feeds = n, cap = feeds_cap, feed = %feed_url, "refused rename: global feeds ceiling reached");
3683                return Ok(Redirect::to(&format!(
3684                    "/?flash={}",
3685                    qenc(
3686                        "This instance is at its feed capacity right now. Please try again later."
3687                    )
3688                ))
3689                .into_response());
3690            }
3691            Ok(_) => {}
3692            Err(err) => warn!(%err, "could not count feeds for global-cap check; allowing"),
3693        }
3694    }
3695
3696    let mut sub = existing;
3697    sub.url = feed_url;
3698    sub.title = form
3699        .title
3700        .map(|t| t.trim().to_string())
3701        .filter(|t| !t.is_empty());
3702    sub.folder = form
3703        .folder
3704        .map(|f| f.trim().to_string())
3705        .filter(|f| !f.is_empty());
3706    // `createdAt` and `private` carry over untouched — neither is a property of
3707    // which feed URL the subscription points at.
3708    //
3709    // `siteUrl` and `fetchHint` ARE properties of the specific feed, so a
3710    // repoint drops them rather than leaving a site link for the old feed
3711    // hanging off the new one. An explicit form value still wins if the form
3712    // ever starts carrying one.
3713    match form
3714        .site_url
3715        .map(|t| t.trim().to_string())
3716        .filter(|t| !t.is_empty())
3717    {
3718        Some(site) => sub.site_url = Some(site),
3719        None if url_changed => sub.site_url = None,
3720        None => {}
3721    }
3722    if url_changed {
3723        sub.fetch_hint = None;
3724    }
3725
3726    // Keep the local cache title in step for the loose-feed fallback path —
3727    // for a row this instance would have. Two cases write nothing:
3728    //
3729    // - not storable (an existing at-URI with the flag off): the record is the
3730    //   reader's to edit, the cache row is not this instance's to create;
3731    // - an unchanged URL with no cache row: a retitle is never the write that
3732    //   CREATES a row. That covers two findings at once — the ceiling is
3733    //   checked on a repoint only, so a retitle must not insert past it; and
3734    //   a secret-bearing URL another client subscribed to has no row (the
3735    //   privacy gate above runs on a repoint only, and `resolve_subscriptions`
3736    //   refuses to cache it), so it cannot enter the shared table here, be
3737    //   polled, fail, and be printed on the admin page. A privacy re-check on
3738    //   this write was the first draft; mutation showed it dead — the row
3739    //   rule already refused every case it would have.
3740    let cache_write =
3741        storable && (url_changed || store::get_feed_by_url(&state.db, &sub.url).await?.is_some());
3742    if !cache_write {
3743        info!(%did, %rkey, url = %sub.url, "renamed a subscription without touching the cache");
3744    } else if let Err(err) = store::upsert_feed(
3745        &state.db,
3746        &store::NewFeed {
3747            url: sub.url.clone(),
3748            title: sub.title.clone(),
3749            site_url: sub.site_url.clone(),
3750            ..Default::default()
3751        },
3752    )
3753    .await
3754    {
3755        // Not fatal to the rename — the PDS record below is the source of truth
3756        // — but a missing `feeds` row means this subscription is never polled.
3757        warn!(%err, %did, url = %sub.url, "could not update the cached feed row on rename");
3758    }
3759
3760    // **The PDS write decides what the reader is told.**
3761    //
3762    // This used to `warn!` on failure and then redirect exactly as it does on
3763    // success, so a rename that did not happen was indistinguishable from one
3764    // that did — the reader saw their old title come back and had no reason to
3765    // think anything had gone wrong. The PDS record IS the subscription; a
3766    // failure here means nothing was renamed or moved.
3767    match state.repo().update_subscription(&did, &rkey, &sub).await {
3768        Ok(res) => {
3769            info!(%did, %rkey, uri = %res.uri, "renamed/moved subscription");
3770            Ok(Redirect::to("/").into_response())
3771        }
3772        Err(err) => {
3773            warn!(%err, %did, %rkey, "PDS subscription update failed");
3774            Ok(Redirect::to(&format!(
3775                "/?flash={}",
3776                qenc("Could not save that change to your PDS — nothing was renamed or moved.")
3777            ))
3778            .into_response())
3779        }
3780    }
3781}
3782
3783// ---------------------------------------------------------------------------
3784// Folders
3785// ---------------------------------------------------------------------------
3786
3787/// Form body for `POST /folders`.
3788#[derive(Debug, Deserialize)]
3789struct FolderForm {
3790    name: String,
3791}
3792
3793/// `POST /folders` — create a folder record.
3794async fn create_folder(
3795    State(state): State<AppState>,
3796    headers: HeaderMap,
3797    Form(form): Form<FolderForm>,
3798) -> Result<Response, WebError> {
3799    let did = match current_did(&state, &headers).await {
3800        Some(d) => d,
3801        None => return Ok(Redirect::to("/login").into_response()),
3802    };
3803    let name = form.name.trim();
3804    if name.is_empty() {
3805        return Ok(Redirect::to("/").into_response());
3806    }
3807    let folder = Folder::new(name.to_string(), now_rfc3339());
3808    match state.repo().add_folder(&did, &folder).await {
3809        Ok(rkey) => info!(%did, %rkey, name, "created folder record"),
3810        Err(err) => warn!(%err, %did, "PDS folder create failed"),
3811    }
3812    Ok(Redirect::to("/").into_response())
3813}
3814
3815/// `POST /folders/:rkey/rename` — rename a folder record.
3816async fn rename_folder(
3817    State(state): State<AppState>,
3818    headers: HeaderMap,
3819    Path(rkey): Path<String>,
3820    Form(form): Form<FolderForm>,
3821) -> Result<Response, WebError> {
3822    let did = match current_did(&state, &headers).await {
3823        Some(d) => d,
3824        None => return Ok(Redirect::to("/login").into_response()),
3825    };
3826    let name = form.name.trim();
3827    if name.is_empty() {
3828        return Ok(Redirect::to("/").into_response());
3829    }
3830    let folder = Folder::new(name.to_string(), now_rfc3339());
3831    match state.repo().rename_folder(&did, &rkey, &folder).await {
3832        Ok(res) => info!(%did, %rkey, uri = %res.uri, "renamed folder"),
3833        Err(err) => warn!(%err, %did, %rkey, "PDS folder rename failed"),
3834    }
3835    Ok(Redirect::to("/").into_response())
3836}
3837
3838/// `POST /folders/:rkey/delete` — delete a folder record (feeds referencing it
3839/// simply become un-foldered).
3840async fn delete_folder(
3841    State(state): State<AppState>,
3842    headers: HeaderMap,
3843    Path(rkey): Path<String>,
3844) -> Result<Response, WebError> {
3845    let did = match current_did(&state, &headers).await {
3846        Some(d) => d,
3847        None => return Ok(Redirect::to("/login").into_response()),
3848    };
3849    match state.repo().remove_folder(&did, &rkey).await {
3850        Ok(()) => info!(%did, %rkey, "deleted folder record"),
3851        Err(err) => warn!(%err, %did, %rkey, "PDS folder delete failed"),
3852    }
3853    Ok(Redirect::to("/").into_response())
3854}
3855
3856/// Resolve a user-pasted URL to a canonical feed URL: if fetching it yields a
3857/// feed document we take it as-is; if it yields an HTML page we run
3858/// autodiscovery over its `<link rel="alternate">` tags.
3859async fn resolve_feed_url(_config: &Config, input: &str) -> anyhow::Result<String> {
3860    let parsed =
3861        url::Url::parse(input).map_err(|e| anyhow::anyhow!("not a valid URL {input:?}: {e}"))?;
3862
3863    let client = feed::build_client()?;
3864    // Fetch through the SSRF guard: scheme + resolved-IP checks on the URL and
3865    // every redirect hop, so a user-pasted URL can't reach cloud metadata /
3866    // loopback / private hosts.
3867    let resp = crate::net::guarded_get(&client, parsed.as_str(), &[]).await?;
3868    let final_url = resp.url().clone();
3869    let content_type = resp
3870        .headers()
3871        .get(axum::http::header::CONTENT_TYPE)
3872        .and_then(|v| v.to_str().ok())
3873        .unwrap_or("")
3874        .to_ascii_lowercase();
3875    // Cap the body (streamed, aborts over 8 MiB) — never trust Content-Length,
3876    // gzip strips it, and this response is reflected into the UI.
3877    let raw = crate::net::read_capped(resp).await?;
3878    let body = String::from_utf8_lossy(&raw).into_owned();
3879
3880    let looks_like_feed = content_type.contains("xml")
3881        || content_type.contains("rss")
3882        || content_type.contains("atom")
3883        || content_type.contains("application/feed+json")
3884        || {
3885            let head = body.trim_start();
3886            head.starts_with("<?xml")
3887                || head.starts_with("<rss")
3888                || head.starts_with("<feed")
3889                || head.contains("<rss")
3890                || head.contains("<feed")
3891        };
3892    if looks_like_feed {
3893        return Ok(final_url.to_string());
3894    }
3895
3896    match feed::discover_feed(&body, Some(&final_url)) {
3897        Some(u) => Ok(u.to_string()),
3898        None => anyhow::bail!("no feed found at {input} (no autodiscovery link)"),
3899    }
3900}
3901
3902// ---------------------------------------------------------------------------
3903// Login (atproto OAuth via the sidecar)
3904// ---------------------------------------------------------------------------
3905
3906/// Query for `GET /login`.
3907#[derive(Debug, Deserialize, Default)]
3908struct LoginQuery {
3909    #[serde(default)]
3910    handle: Option<String>,
3911    #[serde(default)]
3912    error: Option<String>,
3913    #[serde(default)]
3914    flash: Option<String>,
3915}
3916
3917/// `GET /login` — start the atproto OAuth flow, or render the handle form.
3918///
3919/// **Pre-handshake gate:** starting OAuth (a `?handle=` GET) is refused unless
3920/// the visitor is allowed by [`may_start_oauth`] — an existing beta seat (via
3921/// session cookie *or* the submitted handle resolving to a seated DID) or a
3922/// valid reserving invite cookie. Refusal redirects to `/beta/redeem`. The bare
3923/// form (no handle) always renders.
3924async fn login_form(
3925    State(state): State<AppState>,
3926    headers: HeaderMap,
3927    Query(q): Query<LoginQuery>,
3928) -> Response {
3929    if let Some(handle) = q
3930        .handle
3931        .map(|h| h.trim().to_string())
3932        .filter(|h| !h.is_empty())
3933    {
3934        if !may_start_oauth(&state, &headers, &handle).await {
3935            return Redirect::to("/beta/redeem").into_response();
3936        }
3937        return start_oauth(&state, &handle).await;
3938    }
3939    render(&LoginTemplate {
3940        card: login_card(&state.config),
3941        repo_url: REPO_URL,
3942        error: q.error.unwrap_or_default(),
3943        flash: q.flash.unwrap_or_default(),
3944    })
3945}
3946
3947/// `POST /login` — the handle-form submit: redirect into the sidecar OAuth flow.
3948/// Subject to the same pre-handshake invite gate as `GET /login?handle=`.
3949async fn login_submit(
3950    State(state): State<AppState>,
3951    headers: HeaderMap,
3952    Form(form): Form<LoginForm>,
3953) -> Response {
3954    let handle = form.handle.trim();
3955    if handle.is_empty() {
3956        return login_error(&state, "Enter your atproto handle.");
3957    }
3958    if !may_start_oauth(&state, &headers, handle).await {
3959        return Redirect::to("/beta/redeem").into_response();
3960    }
3961    start_oauth(&state, handle).await
3962}
3963
3964/// Whether this visitor is allowed to *start* the OAuth handshake. The gate
3965/// admits, in order of cost:
3966///
3967/// 1. an existing beta member's cookie session whose DID already holds a seat;
3968/// 2. a fresh visitor carrying a valid reserving invite cookie;
3969/// 3. a cookie-less visitor whose submitted `handle` resolves to a DID that
3970///    already holds a seat — this honors the **seeded admin's first login** on a
3971///    fresh deploy (and any returning member who cleared cookies) without a
3972///    session cookie or an invite code.
3973///
3974/// The cookie/invite fast paths run FIRST and short-circuit, so the network
3975/// handle→DID resolution is only attempted when neither applies. It fails
3976/// CLOSED: a malformed/unresolvable handle, a resolution error/timeout, or a
3977/// resolved DID with no seat all leave the visitor bounced to `/beta/redeem`.
3978/// This keeps the anti-abuse intent — a rando now pays a cheap handle
3979/// resolution instead of a burned sidecar handshake (and `/login` is already in
3980/// the rate-limited path set).
3981async fn may_start_oauth(state: &AppState, headers: &HeaderMap, handle: &str) -> bool {
3982    // The production resolver is the app's existing atproto handle→DID path,
3983    // routed through the SSRF guard. Resolution is injected so tests can exercise
3984    // the gate without a live network call (the guard forbids loopback mocks).
3985    may_start_oauth_with(state, headers, handle, |h| async move {
3986        crate::atproto::resolve_handle(&state.http, &state.config.resolver_base, &h)
3987            .await
3988            .ok()
3989    })
3990    .await
3991}
3992
3993/// Core of [`may_start_oauth`] with the handle→DID resolver injected as `resolve`
3994/// (returning `Some(did)` on success, `None` on any failure/unresolvable handle).
3995/// The cookie + invite fast paths run FIRST and short-circuit, so `resolve` is
3996/// only called when neither admits — keeping the network round-trip off the hot
3997/// path and preserving the fail-closed contract on resolution failure.
3998async fn may_start_oauth_with<F, Fut>(
3999    state: &AppState,
4000    headers: &HeaderMap,
4001    handle: &str,
4002    resolve: F,
4003) -> bool
4004where
4005    F: FnOnce(String) -> Fut,
4006    Fut: std::future::Future<Output = Option<String>>,
4007{
4008    // 1. An already-beta'd session may re-auth freely.
4009    if let Some(did) = current_did(state, headers).await {
4010        if store::has_beta_access(&state.db, &did)
4011            .await
4012            .unwrap_or(false)
4013        {
4014            return true;
4015        }
4016    }
4017    // 2. A valid reserving invite cookie.
4018    if invite_cookie_code(headers, &state.config.cookie_secret).is_some() {
4019        return true;
4020    }
4021    // 3. Cookie-less: honor an existing seat by resolving the submitted handle to
4022    //    a DID (the seeded-admin first-login / cleared-cookies case). Fail closed
4023    //    on any resolution error or unresolvable/malformed handle.
4024    match resolve(handle.to_string()).await {
4025        Some(did) => store::has_beta_access(&state.db, &did)
4026            .await
4027            .unwrap_or(false),
4028        None => {
4029            warn!(%handle, "handle resolution failed in pre-handshake beta gate");
4030            false
4031        }
4032    }
4033}
4034
4035/// Begin the OAuth handshake for `handle`, on whichever backend is live.
4036///
4037/// **On `form-action 'self'` and this redirect.** The Rust arm answers a form
4038/// POST with a redirect straight to the PDS — cross-origin — while the app's CSP
4039/// carries `form-action 'self'`. Browsers have historically disagreed about
4040/// whether that directive applies to redirects following a form submission, and
4041/// if it did here, login would break in a browser while every test passed.
4042///
4043/// It does not, and the evidence is the SIDECAR path, which is live in
4044/// production today: `POST /login` -> 303 to the same-origin `/oauth/login` ->
4045/// 302 to the PDS, cross-origin, under this same CSP. A browser checking the
4046/// whole redirect chain would already be blocking that. One checking only the
4047/// form's action URL sees `/login` in both cases. The two arms differ only in
4048/// how many same-origin hops precede the cross-origin one, so any policy that
4049/// permits the sidecar flow permits this one.
4050///
4051/// The two arms differ in SHAPE, not just in implementation. The sidecar owns
4052/// its own `/login` and its own callback, so starting a login is one redirect
4053/// and nothing is stored here. The Rust backend pushes the authorization
4054/// request itself, which means this app now holds the pending login — and must
4055/// set the browser-binding cookie that the callback will be checked against.
4056async fn start_oauth(state: &AppState, handle: &str) -> Response {
4057    match state.config.repo_backend {
4058        crate::metrics::Backend::Sidecar => {
4059            let url = state.sidecar.login_url(handle, None);
4060            info!(%handle, "redirecting to OAuth sidecar login");
4061            Redirect::to(&url).into_response()
4062        }
4063        crate::metrics::Backend::Rust => {
4064            let Some(runtime) = state.oauth.as_deref() else {
4065                warn!("the rust backend is live but its OAuth runtime is absent");
4066                return login_error(state, "Login is not available right now.");
4067            };
4068            match crate::oauth::login::start(
4069                runtime,
4070                &state.http,
4071                &state.db,
4072                handle,
4073                crate::store::now_unix(),
4074            )
4075            .await
4076            {
4077                Ok(started) => {
4078                    info!(%handle, "pushed authorization request; redirecting to the PDS");
4079                    let mut resp = Redirect::to(&started.authorize_url).into_response();
4080                    set_cookie(
4081                        &mut resp,
4082                        &cookie::sign_value(
4083                            OAUTH_BINDING_COOKIE,
4084                            &started.binding_token,
4085                            &state.config.cookie_secret,
4086                            OAUTH_BINDING_MAX_AGE_SECS,
4087                        ),
4088                    );
4089                    resp
4090                }
4091                Err(err) => {
4092                    // The handle the user typed is logged; the error is not shown
4093                    // to them verbatim, since it can name internal hosts.
4094                    warn!(%err, %handle, "could not start the OAuth login");
4095                    login_error(state, "Could not start login for that handle.")
4096                }
4097            }
4098        }
4099    }
4100}
4101
4102/// Clear the browser-binding cookie. Called on every terminal outcome of a
4103/// callback, successful or not: the pending row is consumed either way, so a
4104/// lingering cookie can only ever match a login that no longer exists.
4105fn clear_binding_cookie(resp: &mut Response) {
4106    set_cookie(
4107        resp,
4108        &format!("{OAUTH_BINDING_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
4109    );
4110}
4111
4112/// Form body for `POST /login`.
4113#[derive(Debug, Deserialize)]
4114struct LoginForm {
4115    handle: String,
4116}
4117
4118/// Query for `GET /oauth/callback`.
4119///
4120/// Carries BOTH shapes, because the two backends deliver different things to
4121/// the same URL: the sidecar hands back a one-shot `session_id` it has already
4122/// exchanged, while the PDS redirects here directly with `code`/`state`/`iss`
4123/// for this app to exchange itself. Which fields are populated is decided by
4124/// which backend started the login, not by which is live now — so a flip with a
4125/// login already in flight still lands in the right arm.
4126#[derive(Debug, Deserialize, Default)]
4127struct CallbackQuery {
4128    /// Sidecar backend: the handoff id.
4129    #[serde(default)]
4130    session_id: Option<String>,
4131    /// Rust backend: the authorization code and its envelope.
4132    #[serde(default)]
4133    code: Option<String>,
4134    #[serde(default)]
4135    state: Option<String>,
4136    #[serde(default)]
4137    iss: Option<String>,
4138    /// JARM, which is not supported — carried only so it can be refused
4139    /// explicitly rather than read as "no code".
4140    #[serde(default)]
4141    response: Option<String>,
4142    #[serde(default)]
4143    error: Option<String>,
4144    #[serde(default)]
4145    error_description: Option<String>,
4146}
4147
4148/// `GET /oauth/callback` — establish the cookie session.
4149///
4150/// **Invite gate:** the verified DID must hold beta access. If it already does
4151/// (existing member / seeded admin) it's admitted directly. Otherwise we bind
4152/// the DID to the reserved invite cookie: `redeem_code` atomically consumes the
4153/// code and grants the seat. A DID with neither is bounced to `/beta/redeem`.
4154async fn oauth_callback(
4155    State(state): State<AppState>,
4156    headers: HeaderMap,
4157    Query(q): Query<CallbackQuery>,
4158) -> Response {
4159    // An error response is handled by the SAME arm that would have handled a
4160    // success, not short-circuited here.
4161    //
4162    // Returning early looks obviously right and is wrong on the Rust path: it
4163    // skips `verify_callback`, which validates `iss` BEFORE reporting the error
4164    // precisely because RFC 9207 §2.4 says a client "MUST NOT assume that the
4165    // error originates from the intended AS". It also leaves the pending row
4166    // unconsumed, so a `state` that has already produced a callback stays usable
4167    // until it expires.
4168    //
4169    // The sidecar arm has no such check to reach, so it is short-circuited
4170    // below, preserving exactly what it did before.
4171    // **The arm is chosen by what the SERVER knows, not by what the caller
4172    // sent.** A `session_id` in the query used to select the sidecar arm on its
4173    // own — so a caller could pick which code path ran, and the sidecar arm has
4174    // no browser-binding check at all. It also short-circuited the error path
4175    // below, skipping the `iss` validation.
4176    //
4177    // Requiring the Rust runtime to be absent, or a sidecar backend to be the
4178    // configured one, means the selection follows this deployment's own
4179    // configuration. A login started before a flip still completes, because the
4180    // Rust arm is reached whenever the Rust runtime exists and can match the
4181    // `state` against a pending row it actually wrote.
4182    // The sidecar hands off in TWO shapes, not one: `?session_id=…` on success
4183    // and `?error=…&error_description=…` on its own failure. Keying only on
4184    // `session_id` sent the failure shape down the Rust arm, which then failed
4185    // with "no `state`" and replaced the specific reason with a generic one —
4186    // and `error_description` is exactly what the sidecar Caddy routing matches
4187    // to send that request here in the first place.
4188    let sidecar_shape =
4189        q.session_id.as_deref().is_some_and(|s| !s.is_empty()) || q.error_description.is_some();
4190    let sidecar_handoff = sidecar_shape
4191        && (state.oauth.is_none() || state.config.repo_backend == crate::metrics::Backend::Sidecar);
4192    if let Some(err) = q.error.clone() {
4193        // **Neither the code nor the description is echoed as sent.**
4194        //
4195        // Both are server-controlled free text arriving on a public GET, so
4196        // anyone who can make a browser fetch this URL chooses them. The raw
4197        // `error` used to go into a `warn!` AND into the rendered login page,
4198        // and `error_description` — arbitrary text, newlines included — went
4199        // into the log verbatim: a log-injection surface on one side and
4200        // attacker-chosen copy in the product's own voice on the other.
4201        //
4202        // `oauth::flow` already decided this exact question for the Rust arm:
4203        // reduce the code to a known slug, drop the description entirely. That
4204        // reasoning is not specific to which arm handles the callback, and this
4205        // one simply never got the same treatment. The description's LENGTH is
4206        // kept, because "the server sent a 4 KB explanation" is occasionally
4207        // worth knowing and cannot be used to inject anything.
4208        let slug = crate::oauth::flow::known_error_slug(&err);
4209        warn!(
4210            error = slug,
4211            desc_len = q.error_description.as_deref().map_or(0, str::len),
4212            "OAuth callback returned an error"
4213        );
4214        if sidecar_handoff || state.oauth.is_none() {
4215            return login_error(&state, &format!("Login failed: {slug}"));
4216        }
4217        // Fall through: the Rust arm consumes the pending row and validates
4218        // `iss` against it, and reports the failure afterwards.
4219    }
4220
4221    // Which arm runs is decided by WHAT ARRIVED, not by which backend is
4222    // currently selected: a login started before a flip must still complete.
4223    let session = if sidecar_handoff {
4224        let session_id = q.session_id.clone().unwrap_or_default();
4225        match state.sidecar.resolve_session(&session_id).await {
4226            Ok(Some(s)) => s,
4227            Ok(None) => {
4228                warn!("OAuth callback session_id did not resolve (expired/unknown)");
4229                return login_error(&state, "Login session expired — please try again.");
4230            }
4231            Err(err) => {
4232                warn!(%err, "failed to resolve OAuth session via the sidecar");
4233                return login_error(&state, "Login failed talking to the auth service.");
4234            }
4235        }
4236    } else {
4237        let Some(runtime) = state.oauth.as_deref() else {
4238            warn!("an OAuth callback arrived with no sidecar session and no Rust runtime");
4239            return login_error(&state, "Login failed: this login could not be completed.");
4240        };
4241        let params = crate::oauth::flow::CallbackParams {
4242            code: q.code.clone(),
4243            state: q.state.clone(),
4244            iss: q.iss.clone(),
4245            // Passed through, NOT dropped: `verify_callback` checks `iss`
4246            // against the pending row's issuer before it reports the error, and
4247            // it cannot do that for an error it never sees.
4248            error: q.error.clone(),
4249            error_description: q.error_description.clone(),
4250            response: q.response.clone(),
4251        };
4252        let binding =
4253            cookie::verify_value(&headers, OAUTH_BINDING_COOKIE, &state.config.cookie_secret);
4254        match crate::oauth::login::complete(
4255            runtime,
4256            &state.http,
4257            &state.db,
4258            &params,
4259            binding.as_deref(),
4260            crate::store::now_unix(),
4261        )
4262        .await
4263        {
4264            Ok(done) => crate::atproto::SidecarSession {
4265                did: done.did,
4266                handle: done.handle,
4267            },
4268            Err(err) => {
4269                // Never echoed to the browser: the message can name the issuer,
4270                // the PDS, and why a binding check failed.
4271                warn!(%err, "could not complete the OAuth callback");
4272                let mut resp = login_error(&state, "Login failed — please try again.");
4273                clear_binding_cookie(&mut resp);
4274                return resp;
4275            }
4276        }
4277    };
4278
4279    // Bind the verified DID to the invite gate. Returns a response only on the
4280    // (rare) failure paths; `Ok(())` means the DID now holds beta access.
4281    let mut clear_invite = false;
4282    if !store::has_beta_access(&state.db, &session.did)
4283        .await
4284        .unwrap_or(false)
4285    {
4286        // Not yet a member: consume the reserved invite code, if any.
4287        let code = match invite_cookie_code(&headers, &state.config.cookie_secret) {
4288            Some(c) => c,
4289            None => {
4290                warn!(did = %session.did, "OAuth callback with no beta access and no invite cookie");
4291                return Redirect::to("/beta/redeem").into_response();
4292            }
4293        };
4294        match store::redeem_code(
4295            &state.db,
4296            &code,
4297            &session.did,
4298            session.handle.as_deref(),
4299            state.config.beta_cap,
4300        )
4301        .await
4302        {
4303            Ok(Ok(())) => {
4304                clear_invite = true;
4305                info!(did = %session.did, "invite code redeemed at OAuth callback; beta access granted");
4306            }
4307            Ok(Err(policy)) => {
4308                warn!(did = %session.did, ?policy, "invite redeem failed at callback");
4309                let mut resp = redeem_bounce(&state, &policy).into_response();
4310                // The reservation is spent/invalid — drop the stale invite cookie.
4311                clear_invite_cookie(&mut resp);
4312                return resp;
4313            }
4314            Err(err) => {
4315                warn!(%err, did = %session.did, "invite redeem infra error at callback");
4316                return login_error(&state, "Login failed while confirming your invite.");
4317            }
4318        }
4319    }
4320
4321    // Mint an opaque, random server-side session id and store the identity under
4322    // it; the cookie carries the (HMAC-signed) sid, never the DID.
4323    let sid = state.sessions.create(Session {
4324        did: session.did.clone(),
4325        handle: session.handle.clone(),
4326    });
4327    let cookie = cookie::sign_session(&sid, &state.config.cookie_secret);
4328    info!(did = %session.did, handle = ?session.handle, "OAuth login OK; session cookie set");
4329
4330    let mut resp = Redirect::to("/").into_response();
4331    set_cookie(&mut resp, &cookie);
4332    clear_binding_cookie(&mut resp);
4333    if clear_invite {
4334        clear_invite_cookie(&mut resp);
4335    }
4336    resp
4337}
4338
4339/// Revoke a DID's OAuth session on BOTH backends, best-effort.
4340///
4341/// Not "whichever backend is live": during a cutover a user's tokens can be in
4342/// either store — they logged in under one backend and are logging out under
4343/// the other. Revoking only the live one would leave a live refresh token
4344/// behind in the other, which is the exact failure sign-out exists to prevent,
4345/// and it would be invisible because the sign-out itself looks successful.
4346///
4347/// Both arms are best-effort. The caller has already decided to sign the user
4348/// out, and a network failure must not trap them in a half-logged-out state.
4349/// How long sign-out will wait for a final read-state flush before revoking
4350/// anyway.
4351///
4352/// Bounded because the flush talks to the user's PDS, and a user trying to leave
4353/// must never be held by a server that is not answering. Three seconds is long
4354/// enough for a healthy `applyWrites` (the production samples run 100–970 ms)
4355/// and short enough that a dead PDS is an inconvenience rather than a trap.
4356const SIGN_OUT_FLUSH_BUDGET: std::time::Duration = std::time::Duration::from_secs(3);
4357
4358/// Flush whatever read-state is still dirty for `did`, then give up quietly.
4359///
4360/// **Called before revoking, because revoking first strands it (#117).**
4361/// `revoke_everywhere` deletes the OAuth session, and a dirty cursor with no
4362/// session cannot be sent by anyone — it parks until the user signs in again,
4363/// which may be never. Flushing first is what stops the common case from
4364/// becoming that.
4365///
4366/// Best-effort by construction: every failure path here falls through to the
4367/// revoke. A flush that times out or errors leaves the cursors dirty, which is
4368/// the parked state the flusher now handles deliberately rather than retrying
4369/// forever.
4370async fn flush_before_revoke(state: &AppState, did: &str) {
4371    match tokio::time::timeout(
4372        SIGN_OUT_FLUSH_BUDGET,
4373        crate::readstate::flush_did(state, did),
4374    )
4375    .await
4376    {
4377        Ok(Ok(())) => {}
4378        Ok(Err(err)) => {
4379            warn!(%did, %err, "sign-out: final read-state flush failed; it will park until next sign-in")
4380        }
4381        Err(_) => warn!(
4382            %did,
4383            budget = ?SIGN_OUT_FLUSH_BUDGET,
4384            "sign-out: final read-state flush timed out; it will park until next sign-in"
4385        ),
4386    }
4387}
4388
4389async fn revoke_everywhere(state: &AppState, did: &str) {
4390    // **Counted under Backend::Sidecar, not left uncounted.** A review found
4391    // that recording only the rust arm let `oauth_revoke` report a clean success
4392    // while every sidecar revocation failed — and for anyone who logged in before
4393    // the cutover, the sidecar store is the ONLY one that held tokens, so the
4394    // rust arm correctly returns NoSession and the metric reads all-clear while
4395    // live refresh tokens sit at the PDS.
4396    //
4397    // Same op name, different backend: the backend column is what distinguishes
4398    // them, so "no revocation failures" means checking both rows, not one.
4399    let sidecar_started = std::time::Instant::now();
4400    let sidecar_ok = match state.sidecar.revoke_session(did).await {
4401        Ok(res) => {
4402            info!(%did, revoked = res.revoked, "sidecar session revoked");
4403            true
4404        }
4405        Err(err) => {
4406            warn!(%did, %err, "sidecar revoke failed; continuing");
4407            false
4408        }
4409    };
4410    state.metrics.record(
4411        crate::metrics::Backend::Sidecar,
4412        "oauth_revoke",
4413        sidecar_started.elapsed().as_micros() as u64,
4414        sidecar_ok,
4415    );
4416
4417    if let Some(runtime) = state.oauth.as_deref() {
4418        let revoke_started = std::time::Instant::now();
4419        let outcome = crate::oauth::revoke::sign_out_discovering(
4420            runtime,
4421            &state.http,
4422            &state.db,
4423            did,
4424            crate::store::now_unix(),
4425        )
4426        .await;
4427        // **Counted, because a warn! nobody reads is not observability.** Until
4428        // this existed, a revocation failure left exactly one trace: a log line.
4429        // "No revocation failures this week" was therefore a statement about
4430        // nobody having looked, which is not the same claim.
4431        //
4432        // NoSession counts as a SUCCESS, deliberately. Logout is idempotent —
4433        // there being nothing to revoke is the correct outcome, not a failure,
4434        // and counting it as an error would make the metric noisy in exactly
4435        // the case that is fine. Only `Failed` means the PDS still holds live
4436        // tokens we asked it to drop.
4437        let revoke_ok = !matches!(outcome, crate::oauth::revoke::Revocation::Failed(_));
4438        state.metrics.record(
4439            crate::metrics::Backend::Rust,
4440            "oauth_revoke",
4441            revoke_started.elapsed().as_micros() as u64,
4442            revoke_ok,
4443        );
4444        match outcome {
4445            crate::oauth::revoke::Revocation::Revoked => {
4446                info!(%did, "rust OAuth session revoked at the PDS")
4447            }
4448            crate::oauth::revoke::Revocation::NoSession => {}
4449            crate::oauth::revoke::Revocation::Failed(reason) => {
4450                warn!(%did, %reason, "rust OAuth revoke failed; the local session is gone regardless")
4451            }
4452        }
4453    }
4454}
4455
4456/// `POST /logout` — end the session everywhere, not just in this browser.
4457///
4458/// Clearing the cookie only stops *this* device from presenting the session;
4459/// the sidecar still holds live OAuth tokens for the DID. So logout now also
4460/// calls the sidecar `POST /internal/revoke {did}`, which revokes the refresh +
4461/// access tokens at the PDS and drops the sidecar's session rows. The local
4462/// registry entry is dropped and the cookie cleared regardless of whether the
4463/// revoke round-trip succeeds (best-effort — a network blip must not trap the
4464/// user in a half-logged-out state).
4465async fn logout(State(state): State<AppState>, headers: HeaderMap) -> Response {
4466    if let Some(user) = current_session(&state, &headers).await {
4467        // Only a real cookie session (`sid` present) has sidecar-held tokens to
4468        // revoke; the dev-DID fallback never handshook the sidecar.
4469        if let Some(sid) = user.sid {
4470            state.sessions.remove(&sid);
4471            // BEFORE the revoke: afterwards there is no session to send it with.
4472            flush_before_revoke(&state, &user.did).await;
4473            revoke_everywhere(&state, &user.did).await;
4474        }
4475    }
4476    let mut resp = Redirect::to("/login").into_response();
4477    set_cookie(
4478        &mut resp,
4479        &format!("{SESSION_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
4480    );
4481    resp
4482}
4483
4484/// Form body for `POST /account/delete` — the confirm-gate. The user must type
4485/// `DELETE` into this field for the purge to run.
4486#[derive(Debug, Deserialize)]
4487struct DeleteAccountForm {
4488    #[serde(default)]
4489    confirm: String,
4490}
4491
4492/// The literal a user must type to confirm the destructive delete.
4493const DELETE_CONFIRM_PHRASE: &str = "DELETE";
4494
4495/// `POST /account/delete` (authed) — the "delete my data" endpoint.
4496///
4497/// Confirm-gated: the form must carry `confirm=DELETE` or we bounce back to
4498/// `/manage` with an explanatory flash and touch nothing. On confirmation it:
4499///   1. purges **every** local row owned by the caller DID (`entry_state`,
4500///      `read_cursor`, `sub_ref`, `beta_access` seat, and any invite codes the
4501///      DID created) via [`store::purge_did_data`], then
4502///   2. calls the sidecar `POST /internal/revoke {did}` so the OAuth tokens are
4503///      revoked at the PDS and the sidecar's session rows are dropped, then
4504///   3. drops the in-memory session and clears the cookie, signing the user out.
4505///
4506/// The subscription/folder/saved *records* in the user's own PDS are
4507/// intentionally left alone — they are the user's data on their own server; the
4508/// `/about` copy and this page's UI both say so, and export stays available.
4509async fn account_delete(
4510    State(state): State<AppState>,
4511    headers: HeaderMap,
4512    Form(form): Form<DeleteAccountForm>,
4513) -> Result<Response, WebError> {
4514    let user = match current_session(&state, &headers).await {
4515        Some(u) => u,
4516        None => return Ok(Redirect::to("/login").into_response()),
4517    };
4518    let did = user.did.clone();
4519
4520    // Confirm-gate: require the exact typed phrase before doing anything.
4521    if form.confirm.trim() != DELETE_CONFIRM_PHRASE {
4522        return Ok(Redirect::to(&format!(
4523            "/manage?flash={}",
4524            qenc("Type DELETE to confirm — nothing was deleted.")
4525        ))
4526        .into_response());
4527    }
4528
4529    // 1. Purge every local row this DID owns (single transaction).
4530    let counts = store::purge_did_data(&state.db, &did).await?;
4531    info!(
4532        %did,
4533        total = counts.total(),
4534        entry_state = counts.entry_state,
4535        read_cursor = counts.read_cursor,
4536        sub_ref = counts.sub_ref,
4537        beta_access = counts.beta_access,
4538        invite_codes = counts.invite_codes,
4539        "account/delete: local rows purged"
4540    );
4541
4542    // 2. Revoke the OAuth session at the sidecar/PDS (best-effort — the local
4543    //    rows are already gone; a network blip must not block the sign-out).
4544    revoke_everywhere(&state, &did).await;
4545
4546    // 3. Drop the in-memory session and clear the cookie: sign the user out.
4547    if let Some(sid) = user.sid {
4548        state.sessions.remove(&sid);
4549    }
4550    let mut resp = Redirect::to(&format!(
4551        "/login?flash={}",
4552        qenc("Your data was deleted and you've been signed out. Thanks for trying FeatherReader.")
4553    ))
4554    .into_response();
4555    set_cookie(
4556        &mut resp,
4557        &format!("{SESSION_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
4558    );
4559    Ok(resp)
4560}
4561
4562/// The `/login` card, shared by the form and its error re-render.
4563fn login_card(config: &Config) -> Card {
4564    Card::public(
4565        config,
4566        "/login",
4567        "Sign in — FeatherReader",
4568        "Sign in to FeatherReader with your atproto handle. You approve access on \
4569         your own server — no signup, no password.",
4570    )
4571}
4572
4573/// Re-render the login form with an error banner.
4574fn login_error(state: &AppState, msg: &str) -> Response {
4575    render(&LoginTemplate {
4576        card: login_card(&state.config),
4577        repo_url: REPO_URL,
4578        error: msg.to_string(),
4579        flash: String::new(),
4580    })
4581}
4582
4583// ---------------------------------------------------------------------------
4584// Closed-beta invite gate (self-serve redeem + admin mint)
4585// ---------------------------------------------------------------------------
4586
4587/// Form body for `POST /beta/redeem`.
4588#[derive(Debug, Deserialize)]
4589struct RedeemForm {
4590    code: String,
4591}
4592
4593/// `GET /beta/redeem` — render the invite-redeem page. If the seat cap is
4594/// already full we render the "capacity full" variant (no form).
4595async fn beta_redeem_form(State(state): State<AppState>) -> Response {
4596    let full = store::count_beta_access(&state.db)
4597        .await
4598        .map(|n| n >= state.config.beta_cap)
4599        .unwrap_or(false);
4600    render(&BetaRedeemTemplate {
4601        card: redeem_card(&state.config),
4602        repo_url: REPO_URL,
4603        error: String::new(),
4604        capacity_full: full,
4605    })
4606}
4607
4608/// `POST /beta/redeem` — the **pre-handshake** reservation.
4609///
4610/// Validates the pasted code is *redeemable right now* (exists, active,
4611/// unexpired, and a seat is free) WITHOUT consuming it or binding a DID — the
4612/// visitor has no DID yet. On success it sets a short-lived signed invite cookie
4613/// reserving intent to redeem this code, then sends the visitor to `/login`. The
4614/// OAuth callback later binds the verified DID and atomically consumes the code
4615/// (`store::redeem_code`). This ordering means a non-invited visitor can never
4616/// start OAuth (and burn a sidecar handshake).
4617async fn beta_redeem_submit(
4618    State(state): State<AppState>,
4619    Form(form): Form<RedeemForm>,
4620) -> Response {
4621    let code = form.code.trim().to_uppercase();
4622    if code.is_empty() {
4623        return render(&BetaRedeemTemplate {
4624            card: redeem_card(&state.config),
4625            repo_url: REPO_URL,
4626            error: "Enter your invite code.".to_string(),
4627            capacity_full: false,
4628        });
4629    }
4630
4631    match preflight_code(&state, &code).await {
4632        Ok(()) => {
4633            let cookie = sign_invite(&code, &state.config.cookie_secret);
4634            let mut resp = Redirect::to("/login").into_response();
4635            set_cookie(&mut resp, &cookie);
4636            info!("invite code preflight OK; reserving intent + redirecting to /login");
4637            resp
4638        }
4639        Err(policy) => {
4640            warn!(?policy, "invite code preflight rejected");
4641            redeem_bounce(&state, &policy)
4642        }
4643    }
4644}
4645
4646/// Read-only preflight of an invite code for the pre-handshake reservation:
4647/// verify it exists, is active, is not past `expires_at`, and that a seat is
4648/// free — mirroring the checks `store::redeem_code` will re-run atomically at
4649/// callback time. Does NOT consume the code or grant a seat. Returns the same
4650/// typed [`store::RedeemError`] variants so the two paths share one message map.
4651async fn preflight_code(state: &AppState, code: &str) -> Result<(), store::RedeemError> {
4652    // Cap check first: a clear "capacity full" beats "code invalid" when both.
4653    // FAIL CLOSED on a count error — an `unwrap_or(0)` would let a DB blip read as
4654    // "0 seats used" and wave the redeem through the preflight. (`redeem_code`
4655    // still backstops the real cap inside its tx, so this is a consistency /
4656    // defence-in-depth fix, not the only guard.) Treat an unverifiable count as
4657    // capacity-full: the redeemer sees "at capacity, try later" rather than a mint
4658    // that might overrun the cap.
4659    let count = match store::count_beta_access(&state.db).await {
4660        Ok(n) => n,
4661        Err(err) => {
4662            warn!(%err, "preflight_code: count_beta_access failed; failing closed");
4663            return Err(store::RedeemError::CapacityFull);
4664        }
4665    };
4666    if count >= state.config.beta_cap {
4667        return Err(store::RedeemError::CapacityFull);
4668    }
4669    // Look up the code's current status + expiry (read-only).
4670    let row = sqlx::query_as::<_, (String, i64)>(
4671        "SELECT status, expires_at FROM invite_codes WHERE code = ?1",
4672    )
4673    .bind(code)
4674    .fetch_optional(&state.db)
4675    .await
4676    .ok()
4677    .flatten();
4678    let (status, expires_at) = match row {
4679        Some(r) => r,
4680        None => return Err(store::RedeemError::NotFound),
4681    };
4682    let now = chrono::Utc::now().timestamp();
4683    match status.as_str() {
4684        "active" if expires_at >= now => Ok(()),
4685        "active" => Err(store::RedeemError::Expired),
4686        "expired" => Err(store::RedeemError::Expired),
4687        // "redeemed" or anything else non-active.
4688        _ => Err(store::RedeemError::AlreadyRedeemed),
4689    }
4690}
4691
4692/// Map a [`store::RedeemError`] to the invite page with the right message. Used
4693/// by both the preflight (`POST /beta/redeem`) and the callback bind path.
4694fn redeem_bounce(state: &AppState, policy: &store::RedeemError) -> Response {
4695    use store::RedeemError::*;
4696    let (msg, capacity_full) = match policy {
4697        NotFound => ("That invite code isn't valid.", false),
4698        Expired => ("That invite code has expired.", false),
4699        AlreadyRedeemed => ("That invite code has already been used.", false),
4700        CapacityFull => ("", true),
4701    };
4702    render(&BetaRedeemTemplate {
4703        card: redeem_card(&state.config),
4704        repo_url: REPO_URL,
4705        error: msg.to_string(),
4706        capacity_full,
4707    })
4708}
4709
4710/// The `/beta/redeem` card, shared by the form, its re-renders and the claim
4711/// link's bounce.
4712fn redeem_card(config: &Config) -> Card {
4713    Card::public(
4714        config,
4715        "/beta/redeem",
4716        "Redeem an invite — FeatherReader",
4717        "Redeem a closed-beta invite code for this FeatherReader instance, then sign \
4718         in with your atproto handle.",
4719    )
4720}
4721
4722/// Query for `POST /admin/invites` — how many codes to mint (`?n=`, default 1).
4723#[derive(Debug, Deserialize, Default)]
4724struct MintQuery {
4725    #[serde(default)]
4726    n: Option<u32>,
4727}
4728
4729/// `POST /admin/invites?n=N` — mint N invite codes.
4730///
4731/// `GET /oauth/client-metadata.json` — the client's published identity.
4732///
4733/// **This URL IS the `client_id`.** The PDS fetches it during every login and
4734/// caches it against every existing grant, so it must keep answering at exactly
4735/// this path across the cutover — the sidecar serves the same document at the
4736/// same URL today, proxied by the edge.
4737///
4738/// Served whatever backend is live: a request that arrives here is from a PDS
4739/// resolving our identity, and it has no idea which of our two implementations
4740/// is currently answering repo calls.
4741async fn oauth_client_metadata(State(state): State<AppState>) -> Response {
4742    let Some(runtime) = state.oauth.as_deref() else {
4743        // The sidecar is serving this path in front of us, or nothing is.
4744        return (StatusCode::NOT_FOUND, "no client metadata\n").into_response();
4745    };
4746    axum::Json(crate::oauth::metadata::client_metadata(&runtime.client)).into_response()
4747}
4748
4749/// `GET /oauth/jwks.json` — the client's public signing key.
4750///
4751/// Production only. The localhost dev client is a PUBLIC client: it registers no
4752/// key and signs no assertions, so publishing a JWKS there would advertise a
4753/// credential that is never used — and would make a dev deployment look like a
4754/// confidential client to anyone reading it.
4755async fn oauth_jwks(State(state): State<AppState>) -> Response {
4756    let Some(runtime) = state.oauth.as_deref() else {
4757        return (StatusCode::NOT_FOUND, "no jwks\n").into_response();
4758    };
4759    match runtime.client_key.as_ref() {
4760        Some(key) => match key.jwks_document() {
4761            Ok(doc) => axum::Json(doc).into_response(),
4762            Err(err) => {
4763                warn!(%err, "could not render the client JWKS");
4764                (StatusCode::INTERNAL_SERVER_ERROR, "jwks unavailable\n").into_response()
4765            }
4766        },
4767        None => (StatusCode::NOT_FOUND, "this client publishes no jwks\n").into_response(),
4768    }
4769}
4770
4771/// How many failing feeds `/admin/metrics` will name. One response, so bounded.
4772const ADMIN_FAILING_FEED_LIMIT: i64 = 200;
4773
4774/// `GET /admin/metrics` — repo-op latency for both backends, as plain text.
4775///
4776/// Admin-gated on the same rule as the invite minter: the table names every
4777/// operation the reader performs and how often each fails, which is an
4778/// operational picture rather than public information.
4779///
4780/// Text, not JSON or HTML: it is read by a person deciding whether the cutover
4781/// is safe, and the comparison is two rows side by side.
4782async fn admin_metrics(State(state): State<AppState>, headers: HeaderMap) -> Response {
4783    let did = match current_did(&state, &headers).await {
4784        Some(d) => d,
4785        None => return (StatusCode::UNAUTHORIZED, "sign in first\n").into_response(),
4786    };
4787    if !state.config.admin_seed_dids().iter().any(|d| d == &did) {
4788        warn!(%did, "admin metrics denied: not an admin-seed DID");
4789        return (StatusCode::FORBIDDEN, "not an admin\n").into_response();
4790    }
4791
4792    // Flush first, so the table includes this process's traffic up to now.
4793    // Then read the PERSISTED rows, which is the only place both backends can
4794    // appear at once -- a flip is a restart, and in-process memory only ever
4795    // holds the backend currently running.
4796    if let Err(err) =
4797        crate::metrics::flush(&state.metrics, &state.db, crate::store::now_unix()).await
4798    {
4799        warn!(%err, "could not flush repo timings before rendering");
4800    }
4801    let rows = match crate::metrics::persisted_rows(&state.db).await {
4802        Ok(rows) => rows,
4803        Err(err) => {
4804            warn!(%err, "could not read persisted repo timings");
4805            return (StatusCode::INTERNAL_SERVER_ERROR, "metrics unavailable\n").into_response();
4806        }
4807    };
4808
4809    // The live backend is named at the top: a table of two populated rows is
4810    // ambiguous about which one is currently serving users.
4811    // Parked read-state, alongside the timings. The flusher no longer logs
4812    // these every round (#117), so without a number here the state would be
4813    // silent — which is the failure the noisy loop at least did not have.
4814    let parked = match crate::store::parked_readstate_dids(&state.db).await {
4815        Ok(n) => n.to_string(),
4816        Err(err) => {
4817            warn!(%err, "could not count parked read-state DIDs");
4818            "unknown".to_string()
4819        }
4820    };
4821    // **The half the public histogram cannot carry.** `/stats` reports counts by
4822    // cause and nothing else, deliberately — but `fetch` covers DNS failure,
4823    // timeout, SSRF refusal AND this reader's own bugs, so the count alone
4824    // cannot separate "the publishers are gone" from "we are broken". #159 was
4825    // the latter and took a production investigation to establish. Named feeds
4826    // and their error text belong here, behind ALLOWED_DIDS.
4827    let failing = match crate::store::failing_feeds(&state.db, ADMIN_FAILING_FEED_LIMIT).await {
4828        Ok(f) => f,
4829        Err(err) => {
4830            warn!(%err, "could not list failing feeds");
4831            Vec::new()
4832        }
4833    };
4834    let mut failing_block = String::new();
4835    if !failing.is_empty() {
4836        failing_block.push_str("\nfailing feeds (worst first)\n");
4837        for f in &failing {
4838            failing_block.push_str(&format!(
4839                "  {:>4}x  {:<8}  {}\n          {}\n",
4840                f.consecutive_errors,
4841                f.kind.as_deref().unwrap_or("unknown"),
4842                f.url,
4843                f.detail.as_deref().unwrap_or("(no detail recorded)"),
4844            ));
4845        }
4846    }
4847
4848    // **Capacity that no other page can show.** The global ceiling counts every
4849    // row (`store::count_feeds`), but `/stats` measures the poller and excludes
4850    // unpollable ones — so an instance can be at its cap with every public
4851    // number saying otherwise. A review found exactly that gap.
4852    let unpollable = match crate::store::unpollable_feeds(&state.db).await {
4853        Ok(n) => n,
4854        Err(err) => {
4855            warn!(%err, "could not count unpollable feeds");
4856            -1
4857        }
4858    };
4859    let cached = crate::store::count_feeds(&state.db).await.unwrap_or(-1);
4860
4861    let body = format!(
4862        "live backend: {}\nparked read-state DIDs: {}\n\
4863         feeds cached: {} (ceiling {}), of which unpollable: {}\n\n{}{}",
4864        state.config.repo_backend.as_str(),
4865        parked,
4866        cached,
4867        state.config.max_feeds_global,
4868        unpollable,
4869        crate::metrics::render(&rows),
4870        failing_block,
4871    );
4872    (StatusCode::OK, body).into_response()
4873}
4874
4875/// Authorized ONLY for a live session whose DID is in the `ALLOWED_DIDS` admin
4876/// seed (`config.admin_seed_dids`). Returns the freshly-minted codes as
4877/// newline-separated `text/plain`. Deliberately minimal (no HTML UI).
4878async fn admin_mint_invites(
4879    State(state): State<AppState>,
4880    headers: HeaderMap,
4881    Query(q): Query<MintQuery>,
4882) -> Response {
4883    // Require a real, current session (not just a DID string) whose DID is an
4884    // admin-seed DID. `current_did` already re-checks the beta gate.
4885    let did = match current_did(&state, &headers).await {
4886        Some(d) => d,
4887        None => return (StatusCode::UNAUTHORIZED, "sign in first\n").into_response(),
4888    };
4889    if !state.config.admin_seed_dids().iter().any(|d| d == &did) {
4890        warn!(%did, "admin mint denied: not an admin-seed DID");
4891        return (StatusCode::FORBIDDEN, "not an admin\n").into_response();
4892    }
4893
4894    let n = q.n.unwrap_or(1).clamp(1, 100);
4895    let mut codes = Vec::with_capacity(n as usize);
4896    for _ in 0..n {
4897        match store::mint_code(&state.db, &did, INVITE_TTL_SECS).await {
4898            Ok(code) => codes.push(code),
4899            Err(err) => {
4900                warn!(%err, %did, "admin mint_code failed");
4901                return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
4902            }
4903        }
4904    }
4905    info!(%did, count = codes.len(), "admin minted invite codes");
4906    let mut body = codes.join("\n");
4907    body.push('\n');
4908    (StatusCode::OK, body).into_response()
4909}
4910
4911// ---------------------------------------------------------------------------
4912// Bot claim link: /claim?t=<token>  +  POST /bot/claims (shared-secret mint)
4913// ---------------------------------------------------------------------------
4914
4915/// Query for `GET /claim`.
4916#[derive(Debug, Deserialize)]
4917struct ClaimQuery {
4918    /// The opaque claim token from the bot's public follow-back skeet.
4919    t: Option<String>,
4920}
4921
4922/// `GET /claim?t=<token>` — redeem a bot-issued claim link.
4923///
4924/// The follow→invite bot posts a public skeet mentioning a new follower with a
4925/// link here. The token wraps a pre-minted invite code (never the raw code — see
4926/// [`sign_claim_token`]). On a valid, still-redeemable token this behaves exactly
4927/// like a successful `POST /beta/redeem`: it sets the reserving `fr_invite`
4928/// cookie and sends the visitor to `/login`, so they flow through OAuth and the
4929/// callback atomically consumes the code (`store::redeem_code`) — the same
4930/// machinery as a pasted code. On any failure it bounces to the invite page with
4931/// the matching message.
4932///
4933/// Single-use / grabbability: a token in a public URL is grabbable. The code it
4934/// wraps is single-use (redeem flips `active→redeemed`), and `preflight_code`
4935/// here rejects an already-used / expired / capacity-full code before reserving,
4936/// so a replayed link past the first successful claim is refused. The residual
4937/// window is the same as any pasted invite code: whoever completes OAuth *first*
4938/// with a live reservation wins the seat. The per-IP rate limit on `/claim`
4939/// blunts brute-force enumeration.
4940async fn claim(State(state): State<AppState>, Query(q): Query<ClaimQuery>) -> Response {
4941    let token = match q.t {
4942        Some(t) if !t.is_empty() => t,
4943        _ => {
4944            warn!("claim link with no token");
4945            return redeem_bounce(&state, &store::RedeemError::NotFound);
4946        }
4947    };
4948
4949    // Unwrap the token → the invite code it reserves. A tampered/forged token
4950    // yields nothing → treat as an invalid code (don't leak whether it parsed).
4951    let code = match claim_token_code(&token, &state.config.cookie_secret) {
4952        Some(c) => c,
4953        None => {
4954            warn!("claim token invalid (bad signature / malformed)");
4955            return redeem_bounce(&state, &store::RedeemError::NotFound);
4956        }
4957    };
4958
4959    // Re-run the same preflight as the pasted-code path: exists, active,
4960    // unexpired, seat free. This is what makes a replayed link past first-claim
4961    // (or past cap) fail cleanly.
4962    match preflight_code(&state, &code).await {
4963        Ok(()) => {
4964            let cookie = sign_invite(&code, &state.config.cookie_secret);
4965            let mut resp = Redirect::to("/login").into_response();
4966            set_cookie(&mut resp, &cookie);
4967            info!("claim token preflight OK; reserving intent + redirecting to /login");
4968            resp
4969        }
4970        Err(policy) => {
4971            warn!(?policy, "claim token preflight rejected");
4972            redeem_bounce(&state, &policy)
4973        }
4974    }
4975}
4976
4977/// The JSON body `POST /bot/claims` accepts — the follower the claim is FOR.
4978///
4979/// Passing the follower DID makes the APP the authoritative deduper: the app can
4980/// short-circuit a DID that already holds a seat, and return the SAME code for a
4981/// DID that already has an outstanding claim — so a bot-host state loss cannot
4982/// re-mint or re-post per follower. Handle is advisory (logs only).
4983#[derive(Debug, Default, Deserialize)]
4984struct BotClaimRequest {
4985    /// The follower's DID (the idempotency key). Optional for backward-compat: an
4986    /// omitted DID falls back to the old un-keyed mint (no server-side dedupe).
4987    #[serde(default)]
4988    did: Option<String>,
4989    /// The follower's handle (advisory; recorded for operator logs only).
4990    #[serde(default)]
4991    #[allow(dead_code)]
4992    handle: Option<String>,
4993}
4994
4995/// The JSON body `POST /bot/claims` returns on success.
4996#[derive(Debug, serde::Serialize)]
4997struct BotClaimResponse {
4998    /// Server-side dedupe outcome, so the bot knows whether to post:
4999    /// `"minted"` (a fresh code — post the claim link), `"existing"` (this DID
5000    /// already had an outstanding claim; the SAME code/token/url is returned, so an
5001    /// idempotent re-post is safe), or `"already_seated"` (this DID already holds
5002    /// beta access; code/token/url are empty and the bot should post NOTHING).
5003    status: &'static str,
5004    /// The bare invite code (`FEATHER-…`) — for the bot's own logs/idempotency
5005    /// store. NEVER post this publicly; post the `url` instead. Empty when
5006    /// `already_seated`.
5007    code: String,
5008    /// The opaque claim token (the code wrapped + signed). Empty when
5009    /// `already_seated`.
5010    token: String,
5011    /// The full claim URL to put in the public skeet: `${public_url}/claim?t=…`.
5012    /// Empty when `already_seated`.
5013    url: String,
5014}
5015
5016/// `POST /bot/claims` — headless, shared-secret mint of a claim link.
5017///
5018/// Auth is a bearer shared secret in the `X-Bot-Secret` header (== the Fly secret
5019/// `FEATHERREADER_BOT_SECRET`), NOT an OAuth cookie — so the homelab-hosted bot
5020/// can call it. When `FEATHERREADER_BOT_SECRET` is unset the endpoint is DISABLED
5021/// (503), so a bare/dev instance never exposes an unauthenticated mint.
5022///
5023/// Server-side DID idempotency (the authoritative dedupe backstop): the request
5024/// body carries the follower `did`. The app — not the bot's local SQLite — is the
5025/// source of truth, so a bot-host state loss cannot re-mint or re-post per
5026/// follower:
5027///   * DID already holds beta access → `200 {status:"already_seated"}` (empty
5028///     code/url; the bot marks it handled and posts NOTHING);
5029///   * DID already has an outstanding active claim → `200 {status:"existing"}`
5030///     returning the SAME code/token/url (idempotent — never a second mint);
5031///   * otherwise mint a fresh code recorded FOR that DID → `200 {status:"minted"}`.
5032///
5033/// Cap accounting: the bot must not promise more claims than seats remain, so
5034/// this refuses with `409 Conflict {"error":"full"}` when
5035/// `beta_access + outstanding active codes >= FEATHERREADER_BETA_CAP`. (The
5036/// redeem-time cap in `store::redeem_code` is still the hard backstop.) The count
5037/// queries FAIL CLOSED: a DB error propagates as `500` rather than reading 0 and
5038/// minting past the cap.
5039///
5040/// On a fresh mint it uses the generous claim TTL (`FEATHERREADER_CLAIM_TTL_SECS`,
5041/// default 14d — the admin browser flow's 30-min TTL would expire before the
5042/// follower taps an async-delivered link).
5043async fn bot_mint_claim(
5044    State(state): State<AppState>,
5045    headers: HeaderMap,
5046    body: axum::body::Bytes,
5047) -> Response {
5048    // 1. The endpoint is OFF unless a bot secret is configured.
5049    let bot_secret = match state.config.bot_secret.as_deref() {
5050        Some(s) => s,
5051        None => {
5052            warn!(
5053                "POST /bot/claims called but FEATHERREADER_BOT_SECRET is unset (endpoint disabled)"
5054            );
5055            return (
5056                StatusCode::SERVICE_UNAVAILABLE,
5057                "bot mint endpoint disabled (FEATHERREADER_BOT_SECRET unset)\n",
5058            )
5059                .into_response();
5060        }
5061    };
5062
5063    // 2. Constant-time bearer check on the X-Bot-Secret header.
5064    let presented = headers
5065        .get("x-bot-secret")
5066        .and_then(|v| v.to_str().ok())
5067        .unwrap_or("");
5068    if !bot_secret_matches(presented, bot_secret) {
5069        warn!("POST /bot/claims rejected: bad or missing X-Bot-Secret");
5070        return (StatusCode::UNAUTHORIZED, "bad bot secret\n").into_response();
5071    }
5072
5073    // 2b. Parse the (optional) JSON body → the follower DID/handle. An empty body
5074    // (legacy caller) parses to an all-None request; a malformed body is a 400.
5075    let req: BotClaimRequest = if body.is_empty() {
5076        BotClaimRequest::default()
5077    } else {
5078        match serde_json::from_slice(&body) {
5079            Ok(r) => r,
5080            Err(err) => {
5081                warn!(%err, "POST /bot/claims: bad JSON body");
5082                return (StatusCode::BAD_REQUEST, "bad json body\n").into_response();
5083            }
5084        }
5085    };
5086    let follower_did = req.did.as_deref().filter(|d| !d.is_empty());
5087
5088    // 3. Server-side DID idempotency (only when a DID was supplied):
5089    if let Some(did) = follower_did {
5090        // 3a. Already seated → tell the bot to post nothing.
5091        match store::has_beta_access(&state.db, did).await {
5092            Ok(true) => {
5093                info!("bot mint: DID already holds beta access; already_seated");
5094                return bot_claim_json(BotClaimResponse {
5095                    status: "already_seated",
5096                    code: String::new(),
5097                    token: String::new(),
5098                    url: String::new(),
5099                });
5100            }
5101            Ok(false) => {}
5102            Err(err) => {
5103                // Fail closed: a DB error must not fall through to a fresh mint.
5104                warn!(%err, "bot mint: has_beta_access failed");
5105                return (StatusCode::INTERNAL_SERVER_ERROR, "lookup failed\n").into_response();
5106            }
5107        }
5108        // 3b. Outstanding active claim for this DID → return the SAME code (no
5109        // second mint). This is what survives a bot-host state loss.
5110        match store::find_active_code_for_did(&state.db, did).await {
5111            Ok(Some(code)) => {
5112                info!("bot mint: existing outstanding claim for DID; returning same code");
5113                let token = sign_claim_token(&code, &state.config.cookie_secret);
5114                let url = format!("{}/claim?t={}", state.config.public_url, qenc(&token));
5115                return bot_claim_json(BotClaimResponse {
5116                    status: "existing",
5117                    code,
5118                    token,
5119                    url,
5120                });
5121            }
5122            Ok(None) => {}
5123            Err(err) => {
5124                warn!(%err, "bot mint: find_active_code_for_did failed");
5125                return (StatusCode::INTERNAL_SERVER_ERROR, "lookup failed\n").into_response();
5126            }
5127        }
5128    }
5129
5130    // 4. Cap accounting: seats already granted + outstanding unredeemed codes.
5131    //    FAIL CLOSED — a count error is a 500, not a silent mint past the cap.
5132    let granted = match store::count_beta_access(&state.db).await {
5133        Ok(n) => n,
5134        Err(err) => {
5135            warn!(%err, "bot mint: count_beta_access failed; failing closed");
5136            return (StatusCode::INTERNAL_SERVER_ERROR, "count failed\n").into_response();
5137        }
5138    };
5139    let outstanding = match store::count_active_codes(&state.db).await {
5140        Ok(n) => n,
5141        Err(err) => {
5142            warn!(%err, "bot mint: count_active_codes failed; failing closed");
5143            return (StatusCode::INTERNAL_SERVER_ERROR, "count failed\n").into_response();
5144        }
5145    };
5146    if granted + outstanding >= state.config.beta_cap {
5147        info!(
5148            granted,
5149            outstanding,
5150            cap = state.config.beta_cap,
5151            "bot mint refused: at capacity"
5152        );
5153        return (
5154            StatusCode::CONFLICT,
5155            [(header::CONTENT_TYPE, "application/json")],
5156            "{\"error\":\"full\"}\n",
5157        )
5158            .into_response();
5159    }
5160
5161    // 5. Mint with the generous claim TTL, recording the follower DID (when given)
5162    //    so a re-request for the same DID returns THIS code idempotently.
5163    let bot_did = state
5164        .config
5165        .admin_seed_dids()
5166        .first()
5167        .cloned()
5168        .unwrap_or_else(|| "did:bot:featherreader".to_string());
5169    let minted = match follower_did {
5170        Some(did) => {
5171            store::mint_code_for_did(&state.db, &bot_did, state.config.claim_ttl_secs, did).await
5172        }
5173        None => store::mint_code(&state.db, &bot_did, state.config.claim_ttl_secs).await,
5174    };
5175    let code = match minted {
5176        Ok(c) => c,
5177        // S4: the dedupe check (3b) and this mint are separate statements, so two
5178        // concurrent requests for one DID can both fall through 3b's `Ok(None)`.
5179        // The partial unique index `idx_invite_codes_intended_active` makes the
5180        // loser's INSERT fail (only one active row per intended DID), which
5181        // surfaces here as a conflict. Recover by returning the winner's existing
5182        // code (same shape as the 3b idempotent path) instead of a 500.
5183        Err(err) if follower_did.is_some() && store::is_intended_active_conflict(&err) => {
5184            match store::find_active_code_for_did(&state.db, follower_did.unwrap()).await {
5185                Ok(Some(code)) => {
5186                    info!("bot mint: lost the mint race; returning the concurrently-minted code");
5187                    let token = sign_claim_token(&code, &state.config.cookie_secret);
5188                    let url = format!("{}/claim?t={}", state.config.public_url, qenc(&token));
5189                    return bot_claim_json(BotClaimResponse {
5190                        status: "existing",
5191                        code,
5192                        token,
5193                        url,
5194                    });
5195                }
5196                // The winner's row vanished between the conflict and this lookup
5197                // (redeemed/expired/purged in the gap) — nothing to hand back.
5198                // Fail closed rather than silently mint past the just-hit guard.
5199                Ok(None) => {
5200                    warn!("bot mint: conflict but no active code found on recovery");
5201                    return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
5202                }
5203                Err(err) => {
5204                    warn!(%err, "bot mint: recovery lookup after conflict failed");
5205                    return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
5206                }
5207            }
5208        }
5209        Err(err) => {
5210            warn!(%err, "bot mint_code failed");
5211            return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
5212        }
5213    };
5214    let token = sign_claim_token(&code, &state.config.cookie_secret);
5215    let url = format!("{}/claim?t={}", state.config.public_url, qenc(&token));
5216    info!("bot minted a claim code + token");
5217
5218    bot_claim_json(BotClaimResponse {
5219        status: "minted",
5220        code,
5221        token,
5222        url,
5223    })
5224}
5225
5226/// Serialize a [`BotClaimResponse`] to a `200 application/json` response (or a
5227/// `500` if serialization somehow fails).
5228fn bot_claim_json(resp: BotClaimResponse) -> Response {
5229    match serde_json::to_string(&resp) {
5230        Ok(body) => (
5231            StatusCode::OK,
5232            [(header::CONTENT_TYPE, "application/json")],
5233            body,
5234        )
5235            .into_response(),
5236        Err(err) => {
5237            warn!(%err, "serializing bot claim response failed");
5238            (StatusCode::INTERNAL_SERVER_ERROR, "serialize failed\n").into_response()
5239        }
5240    }
5241}
5242
5243/// Constant-time equality for the bot bearer secret (avoid a timing side-channel
5244/// on the shared secret). Delegates to the same `cookie::constant_time_eq` used
5245/// by the HMAC checks so there is one comparator to audit; a length mismatch
5246/// short-circuits to `false`, which is fine — the secret length isn't sensitive.
5247fn bot_secret_matches(presented: &str, expected: &str) -> bool {
5248    cookie::constant_time_eq(presented.as_bytes(), expected.as_bytes())
5249}
5250
5251// ---------------------------------------------------------------------------
5252// Signed, short-lived invite cookie (reuses the session-cookie HMAC helper)
5253// ---------------------------------------------------------------------------
5254
5255/// Sign the reserved invite `code` into a short-lived `Set-Cookie` value. Reuses
5256/// the same HMAC-SHA256 helper as the session cookie; the payload is the code
5257/// itself (base64url) rather than an opaque sid, since the code IS the reserved
5258/// intent the callback consumes.
5259fn sign_invite(code: &str, secret: &str) -> String {
5260    cookie::sign_value(INVITE_COOKIE, code, secret, INVITE_TTL_SECS)
5261}
5262
5263/// Verify + read the reserved invite code out of the request's invite cookie
5264/// (`None` if absent, tampered, or forged). No expiry is enforced here beyond
5265/// the cookie's own `Max-Age`; the atomic `redeem_code` at the callback is the
5266/// authority on the code's live status.
5267fn invite_cookie_code(headers: &HeaderMap, secret: &str) -> Option<String> {
5268    cookie::verify_value(headers, INVITE_COOKIE, secret)
5269}
5270
5271/// Domain-separation label for the claim TOKEN's HMAC (distinct from the
5272/// `fr_invite`/`fr_session` cookie names), so a token can never be replayed as a
5273/// cookie value and vice-versa.
5274const CLAIM_TOKEN_LABEL: &str = "claim-token";
5275
5276/// Sign an invite `code` into a URL-safe claim TOKEN: `b64url(code).<sig>`
5277/// (HMAC-SHA256 over `"claim-token" || 0x00 || code`).
5278///
5279/// NOTE — the token is NOT confidential: the `b64url(code)` half is trivially
5280/// decodable by anyone, so the raw `FEATHER-…` code is effectively public in the
5281/// claim URL. The token's security is INTEGRITY + SINGLE-USE, not secrecy: the
5282/// `<sig>` HMAC means only this instance can MINT a valid token (a forged/guessed
5283/// code won't verify), the wrapped code is single-use (redeem flips
5284/// `active→redeemed`), and `/claim` is per-IP rate-limited. Wrapping keeps the
5285/// token one self-contained string needing no server-side token table; it does
5286/// NOT hide the code.
5287fn sign_claim_token(code: &str, secret: &str) -> String {
5288    cookie::sign_token(CLAIM_TOKEN_LABEL, code, secret)
5289}
5290
5291/// Verify a claim token and return the invite code it wraps (`None` on a tampered
5292/// / forged / malformed token). The code's live status (active/unexpired/seat
5293/// free) is re-checked by `preflight_code`; this only proves the token was minted
5294/// by this instance.
5295fn claim_token_code(token: &str, secret: &str) -> Option<String> {
5296    cookie::verify_token(CLAIM_TOKEN_LABEL, token, secret)
5297}
5298
5299/// Clear the invite cookie on a response (after a successful bind, or when the
5300/// reservation turned out to be stale).
5301fn clear_invite_cookie(resp: &mut Response) {
5302    set_cookie(
5303        resp,
5304        &format!("{INVITE_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
5305    );
5306}
5307
5308// ---------------------------------------------------------------------------
5309// OPML import + export
5310// ---------------------------------------------------------------------------
5311
5312/// `POST /opml` — import subscriptions from an OPML document.
5313///
5314/// Accepts either a multipart file upload (field `file`) or a pasted textarea
5315/// (field `opml`). The parsed feeds each become a `community.lexicon.rss.folder`
5316/// (for any named folders) + a `community.lexicon.rss.subscription` record in the
5317/// user's PDS via the records layer's bulk-add (`add_subscriptions_bulk`, one
5318/// `applyWrites` round-trip per 200 feeds). Feeds are also upserted into the local cache so
5319/// they show immediately; polling is left to the background poller.
5320async fn import_opml(
5321    State(state): State<AppState>,
5322    headers: HeaderMap,
5323    mut multipart: Multipart,
5324) -> Result<Response, WebError> {
5325    let did = match current_did(&state, &headers).await {
5326        Some(d) => d,
5327        None => return Ok(Redirect::to("/login").into_response()),
5328    };
5329    let pool = &state.db;
5330
5331    // Collect the OPML text from whichever field carried it. Multipart errors
5332    // are mapped to their axum-native response so that an over-cap upload (the
5333    // `DefaultBodyLimit` on this route, see `OPML_BODY_LIMIT`) surfaces as
5334    // `413 Payload Too Large` rather than being swallowed by the blanket
5335    // `WebError` → `500` conversion.
5336    let mut opml_text = String::new();
5337    while let Some(field) = multipart.next_field().await.map_err(multipart_response)? {
5338        let name = field.name().unwrap_or("").to_string();
5339        if name == "opml" || name == "file" {
5340            let bytes = field.bytes().await.map_err(multipart_response)?;
5341            if !bytes.is_empty() {
5342                opml_text = String::from_utf8_lossy(&bytes).into_owned();
5343                if name == "file" {
5344                    break;
5345                }
5346            }
5347        }
5348    }
5349
5350    // A parse FAILURE and an empty-but-valid file are different things, and
5351    // `unwrap_or_default` collapsed them: a malformed export was reported to the
5352    // reader as "No feeds found in that OPML", which sends them looking at their
5353    // old reader for feeds that are right there in the file.
5354    let feeds =
5355        match opml::parse_opml(&opml_text) {
5356            Ok(feeds) => feeds,
5357            Err(err) => {
5358                warn!(%err, %did, "OPML import could not parse the uploaded file");
5359                return Ok(Redirect::to(&format!(
5360                "/?flash={}",
5361                qenc("That file could not be read as OPML. Export it again from your other reader?")
5362            ))
5363                .into_response());
5364            }
5365        };
5366    if feeds.is_empty() {
5367        info!(%did, "OPML import found no feeds");
5368        return Ok(
5369            Redirect::to(&format!("/?flash={}", qenc("No feeds found in that OPML")))
5370                .into_response(),
5371        );
5372    }
5373
5374    // Create any named folders first, mapping folder name → at:// URI so
5375    // subscriptions can reference them.
5376    let now = now_rfc3339();
5377    let mut folder_uris: std::collections::HashMap<String, String> =
5378        std::collections::HashMap::new();
5379    // Reuse existing folders where the name already exists.
5380    if let Ok(existing) = state.repo().list_folders_sorted(&did).await {
5381        for (rkey, folder) in existing {
5382            folder_uris
5383                .entry(folder.name.clone())
5384                .or_insert_with(|| folder_uri(&did, &rkey));
5385        }
5386    }
5387    let mut wanted_folders: Vec<String> = feeds
5388        .iter()
5389        .filter_map(|f| f.folder.clone())
5390        .filter(|n| !n.is_empty())
5391        .collect();
5392    wanted_folders.sort();
5393    wanted_folders.dedup();
5394    for name in wanted_folders {
5395        if folder_uris.contains_key(&name) {
5396            continue;
5397        }
5398        let folder = Folder::new(name.clone(), now.clone());
5399        match state.repo().add_folder(&did, &folder).await {
5400            Ok(rkey) => {
5401                folder_uris.insert(name, folder_uri(&did, &rkey));
5402            }
5403            Err(err) => warn!(%err, %did, "OPML folder create failed"),
5404        }
5405    }
5406
5407    // Build one subscription record per PUBLIC feed + upsert the local cache row.
5408    // Private/paid feeds are SKIPPED entirely (never stored, fetched, or written)
5409    // and reported back to the user — the same public-feeds-only stance as the
5410    // single-add path, so an OPML import can't leak a Substack/Patreon/podcast
5411    // token onto the public network either.
5412    // Per-DID subscription cap: an OPML import must not blow past the cap. Compute
5413    // the remaining headroom (cap − existing) once; public feeds beyond it are
5414    // TRIMMED (not imported) and reported. `<= 0` disables the cap.
5415    let sub_cap = state.config.max_subs_per_did;
5416    let mut headroom: Option<i64> = if sub_cap > 0 {
5417        let existing = store::count_subscriptions_for_did(pool, &did)
5418            .await
5419            .unwrap_or(0);
5420        Some((sub_cap - existing).max(0))
5421    } else {
5422        None
5423    };
5424    let mut trimmed_over_cap: usize = 0;
5425
5426    // Global feeds ceiling: an OPML import must not blow past the shared cache
5427    // ceiling any more than the single-add path may. Seed the remaining global
5428    // headroom (cap − current feeds) once, and only a BRAND-NEW feed URL (one
5429    // not already cached) consumes it. Existing/duplicate URLs add no row and
5430    // are always allowed. Feeds past the ceiling are TRIMMED and reported.
5431    // `<= 0` disables the ceiling.
5432    let feeds_cap = state.config.max_feeds_global;
5433    let mut global_headroom: Option<i64> = if feeds_cap > 0 {
5434        let existing = store::count_feeds(pool).await.unwrap_or(0);
5435        Some((feeds_cap - existing).max(0))
5436    } else {
5437        None
5438    };
5439    let mut trimmed_over_global: usize = 0;
5440
5441    let mut subs = Vec::with_capacity(feeds.len());
5442    let mut skipped_private: Vec<String> = Vec::new();
5443    // Imported into the PDS but not cached locally, so not pollable until the
5444    // next import touches them. Counted rather than only logged — see below.
5445    let mut uncached: usize = 0;
5446    // Entries this instance cannot store at all (an `at://` publication with
5447    // the flag off, an unsupported scheme). Counted, because the `continue`
5448    // below used to increment nothing while the privacy branch beside it
5449    // produced a label — so an OPML from a standard.site-enabled instance
5450    // imported "successfully" with entries missing and no reason given.
5451    let mut skipped_unsupported: usize = 0;
5452    for f in &feeds {
5453        // `xmlUrl` is whatever the uploaded file says, and nothing on this path
5454        // ever parsed it — the single-add path can't reach here because
5455        // `resolve_feed_url` must parse AND successfully fetch first. So
5456        // `javascript:alert(1)` and `file:///etc/passwd` were both accepted,
5457        // cached, and published as records to the user's PUBLIC repo. Note that
5458        // `classify_feed_privacy` does not catch these: both parse cleanly, and
5459        // it returns `Public` for anything unparseable by design.
5460        if !feed::is_storable_feed_url(&f.feed_url, state.config.standard_site) {
5461            info!(
5462                %did,
5463                "skipped an OPML entry whose xmlUrl is not a storable feed URL"
5464            );
5465            skipped_unsupported += 1;
5466            continue;
5467        }
5468        if let feed::FeedPrivacy::Private(reason) = feed::classify_feed_privacy(&f.feed_url) {
5469            info!(feed = %f.feed_url, %reason, %did, "skipped private/paid feed on OPML import (not stored)");
5470            // Report by title where we have one, else the (public-safe) host.
5471            let label = f
5472                .title
5473                .clone()
5474                .filter(|t| !t.trim().is_empty())
5475                .unwrap_or_else(|| private_feed_label(&f.feed_url));
5476            skipped_private.push(label);
5477            continue;
5478        }
5479
5480        // Over-cap: stop importing once headroom is exhausted (count the rest so
5481        // we can tell the user how many were dropped).
5482        if let Some(h) = headroom.as_mut() {
5483            if *h <= 0 {
5484                trimmed_over_cap += 1;
5485                continue;
5486            }
5487        }
5488
5489        // Global ceiling: a brand-new feed URL consumes global headroom. Once
5490        // it's exhausted, refuse to cache further NEW feeds (existing URLs are
5491        // free — they add no row). Checked before decrementing the per-DID
5492        // headroom so a dropped feed doesn't burn the caller's own quota.
5493        let is_new = match store::get_feed_by_url(pool, &f.feed_url).await {
5494            Ok(existing) => existing.is_none(),
5495            // On a lookup error, treat as existing (don't consume global
5496            // headroom) but still allow the upsert to proceed.
5497            Err(err) => {
5498                warn!(%err, feed = %f.feed_url, "get_feed_by_url failed during OPML global-cap check");
5499                false
5500            }
5501        };
5502        if is_new {
5503            if let Some(g) = global_headroom.as_mut() {
5504                if *g <= 0 {
5505                    trimmed_over_global += 1;
5506                    continue;
5507                }
5508                *g -= 1;
5509            }
5510        }
5511
5512        // Passed both caps: consume the per-DID headroom now that the feed is
5513        // actually being imported.
5514        if let Some(h) = headroom.as_mut() {
5515            *h -= 1;
5516        }
5517
5518        let mut sub = Subscription::new(f.feed_url.clone(), now.clone());
5519        sub.title = f.title.clone();
5520        sub.site_url = f.site_url.clone();
5521        sub.folder = f
5522            .folder
5523            .as_ref()
5524            .and_then(|name| folder_uris.get(name).cloned());
5525        subs.push(sub);
5526        // Same support ticket as the single-add path: no `feeds` row means the
5527        // poller never selects this subscription, so the import looks like it
5528        // worked and the feed silently never updates. Counted as well as logged,
5529        // because one line per feed in a 200-feed import is not something anyone
5530        // reads — the count goes to the reader.
5531        if let Err(err) = store::upsert_feed(
5532            pool,
5533            &store::NewFeed {
5534                url: f.feed_url.clone(),
5535                title: f.title.clone(),
5536                site_url: f.site_url.clone(),
5537                ..Default::default()
5538            },
5539        )
5540        .await
5541        {
5542            warn!(%err, %did, url = %f.feed_url, "OPML import could not cache a feed; \
5543                                                  it will not be polled");
5544            uncached += 1;
5545        }
5546    }
5547
5548    // **A failed PDS write is not an import.**
5549    //
5550    // The subscriptions live in the reader's repo; a local `feeds` row is just a
5551    // poller hint. This used to `warn!` and then report "Imported N feeds"
5552    // regardless, so a total failure read as a total success — and the reader
5553    // would only discover otherwise on their next visit, with an empty sidebar.
5554    //
5555    // **And a part-landed write is not a failed one.** The batch goes out in
5556    // calls of at most 200 (#240: the reference PDS refuses more), sent in
5557    // order and stopped at the first failure, so what landed is a prefix of
5558    // `subs` and the error says how long. Saying "nothing was imported" after
5559    // the first 200 of 450 landed would send the reader to import the file
5560    // again, which adds those 200 a second time. Nothing local needs undoing
5561    // either way: the reader's `sub_ref` projection is rebuilt from the repo on
5562    // the next read, and a cached `feeds` row with no subscriber is the same
5563    // poller hint the total-failure path has always left behind.
5564    let landed = match state.repo().add_subscriptions_bulk(&did, &subs).await {
5565        Ok(rkeys) => {
5566            info!(%did, count = rkeys.len(), skipped = skipped_private.len(), "imported OPML subscriptions to PDS (batched)");
5567            rkeys.len()
5568        }
5569        Err(err) => {
5570            let landed = crate::atproto::ApplyWritesIncomplete::of(&err).map_or(0, |p| p.landed);
5571            warn!(%err, %did, landed, total = subs.len(), "OPML PDS batch write failed (feeds cached locally)");
5572            landed
5573        }
5574    };
5575    if landed == 0 && !subs.is_empty() {
5576        return Ok(Redirect::to(&format!(
5577            "/?flash={}",
5578            qenc(
5579                "Could not save those subscriptions to your PDS, so nothing was imported. \
5580                 Try again in a moment."
5581            )
5582        ))
5583        .into_response());
5584    }
5585
5586    // Report the import count, plus any private/paid feeds skipped as unsupported.
5587    let mut flash = if landed < subs.len() {
5588        format!(
5589            "Imported {landed} of {} feeds: your PDS stopped accepting them part-way, so the \
5590             other {} may not have been saved. Importing the same file again would add the first \
5591             {landed} a second time",
5592            subs.len(),
5593            subs.len() - landed
5594        )
5595    } else {
5596        format!("Imported {} feeds", subs.len())
5597    };
5598    if uncached > 0 {
5599        flash.push_str(&format!(
5600            ". {uncached} of them could not be cached locally and may not update until the next import."
5601        ));
5602    }
5603    if trimmed_over_cap > 0 {
5604        flash.push_str(&format!(
5605            ". {trimmed_over_cap} feed(s) not imported: your subscription limit ({sub_cap}) was reached."
5606        ));
5607    }
5608    if trimmed_over_global > 0 {
5609        flash.push_str(&format!(
5610            ". {trimmed_over_global} feed(s) not imported: this instance is at its feed capacity right now."
5611        ));
5612    }
5613    if !skipped_private.is_empty() {
5614        flash.push_str(&format!(
5615            ". {} feed(s) skipped as private/paid: {} — not supported yet (public feeds only for now).",
5616            skipped_private.len(),
5617            skipped_private.join(", ")
5618        ));
5619    }
5620    if skipped_unsupported > 0 {
5621        // By count only — the URL is whatever the file said, and unlike the
5622        // private branch there is no public-safe label to give.
5623        flash.push_str(&format!(
5624            ". {skipped_unsupported} feed(s) skipped: not a kind of feed this instance can subscribe to."
5625        ));
5626    }
5627    Ok(Redirect::to(&format!("/?flash={}", qenc(&flash))).into_response())
5628}
5629
5630/// A public-safe label for a skipped private feed when it has no title: just the
5631/// host, so we never echo the secret-bearing path/query back to the user.
5632fn private_feed_label(url: &str) -> String {
5633    url::Url::parse(url)
5634        .ok()
5635        .and_then(|u| u.host_str().map(str::to_string))
5636        .unwrap_or_else(|| "a private feed".to_string())
5637}
5638
5639/// `GET /opml/export` — export the user's subscriptions + folders as OPML.
5640async fn export_opml(
5641    State(state): State<AppState>,
5642    headers: HeaderMap,
5643) -> Result<Response, WebError> {
5644    let did = match current_did(&state, &headers).await {
5645        Some(d) => d,
5646        None => return Ok(Redirect::to("/login").into_response()),
5647    };
5648
5649    // **An export must never be silently empty.** `unwrap_or_default` here turned
5650    // a failed read into a 200 carrying a zero-feed OPML file — the reader's
5651    // backup, blank, at exactly the moment they reached for it. That was survivable
5652    // while a truncated walk returned `Ok`; now that the walk refuses a short list,
5653    // this is the one caller that converts a refusal into data loss, and it is also
5654    // the recovery route the changelog points a locked-out reader at.
5655    let subs = match state.repo().list_subscriptions_sorted(&did).await {
5656        Ok(subs) => subs,
5657        Err(err) => {
5658            tracing::warn!(%err, did = %did, "refusing to export an OPML we could not read in full");
5659            return Ok(Redirect::to(&format!(
5660                "/manage?flash={}",
5661                qenc(EXPORT_INCOMPLETE_REFUSAL)
5662            ))
5663            .into_response());
5664        }
5665    };
5666    let folders = match state.repo().list_folders_sorted(&did).await {
5667        Ok(folders) => folders,
5668        Err(err) => {
5669            tracing::warn!(%err, did = %did, "refusing to export an OPML without its folders");
5670            return Ok(Redirect::to(&format!(
5671                "/manage?flash={}",
5672                qenc(EXPORT_INCOMPLETE_REFUSAL)
5673            ))
5674            .into_response());
5675        }
5676    };
5677    // The exporter matches a subscription's `folder` at-uri against the folder's
5678    // pair key; our folder pairs are keyed by rkey, so rebuild them as at-uris.
5679    let folder_pairs: Vec<(String, Folder)> = folders
5680        .into_iter()
5681        .map(|(rkey, f)| (folder_uri(&did, &rkey), f))
5682        .collect();
5683
5684    let body = opml::to_opml(&subs, &folder_pairs);
5685    let mut resp = (StatusCode::OK, body).into_response();
5686    resp.headers_mut().insert(
5687        header::CONTENT_TYPE,
5688        "text/x-opml; charset=utf-8".parse().unwrap(),
5689    );
5690    resp.headers_mut().insert(
5691        header::CONTENT_DISPOSITION,
5692        "attachment; filename=\"featherreader-subscriptions.opml\""
5693            .parse()
5694            .unwrap(),
5695    );
5696    Ok(resp)
5697}
5698
5699// ---------------------------------------------------------------------------
5700// Signed session cookie (HMAC-SHA256, dependency-free)
5701// ---------------------------------------------------------------------------
5702
5703/// Set a `Set-Cookie` header on a response (append, so logout+redirect compose).
5704fn set_cookie(resp: &mut Response, cookie: &str) {
5705    if let Ok(value) = axum::http::HeaderValue::from_str(cookie) {
5706        resp.headers_mut()
5707            .append(axum::http::header::SET_COOKIE, value);
5708    }
5709}
5710
5711/// Whether the request came from htmx (the `HX-Request` header).
5712fn is_htmx(headers: &HeaderMap) -> bool {
5713    headers
5714        .get("HX-Request")
5715        .is_some_and(|v| v.as_bytes().eq_ignore_ascii_case(b"true"))
5716}
5717
5718/// Whether a mark-read / star request originated from the single-entry READER
5719/// (as opposed to the list view). The reader's forms tag themselves with
5720/// `X-FR-Reader: 1` via `hx-headers`; the list view's do not. This selects the
5721/// swap fragment: the reader gets an out-of-band action-bar update (its `<li>`
5722/// isn't in the DOM), the list gets the row (`entry_row.html`).
5723fn is_reader_request(headers: &HeaderMap) -> bool {
5724    headers
5725        .get("X-FR-Reader")
5726        .is_some_and(|v| v.as_bytes() == b"1")
5727}
5728
5729/// A tiny, self-contained signed-cookie layer: HMAC-SHA256 over an opaque,
5730/// server-minted **session id** (never the DID — so the cookie can't be forged
5731/// from a resolved victim DID; forging it needs the HMAC secret *and* a live
5732/// server-side session id).
5733mod cookie {
5734    use super::{HeaderMap, SESSION_COOKIE};
5735
5736    /// Sign a session id into a `Set-Cookie` header value: `fr_session=<sid>.<sig>`.
5737    pub fn sign_session(sid: &str, secret: &str) -> String {
5738        sign_value(SESSION_COOKIE, sid, secret, 2_592_000)
5739    }
5740
5741    /// Verify the request's session cookie and return the session id it carries.
5742    pub fn verify_session(headers: &HeaderMap, secret: &str) -> Option<String> {
5743        verify_value(headers, SESSION_COOKIE, secret)
5744    }
5745
5746    /// The HMAC message binding the cookie NAME to its value (`name || 0x00 ||
5747    /// value`), so a signature minted for one cookie can't verify under another —
5748    /// e.g. a value validly signed as `fr_invite` is not accepted as `fr_session`.
5749    /// The NUL separator can't appear in a cookie name, so the encoding is
5750    /// unambiguous.
5751    fn cookie_hmac_msg(name: &str, value: &str) -> Vec<u8> {
5752        let mut msg = Vec::with_capacity(name.len() + 1 + value.len());
5753        msg.extend_from_slice(name.as_bytes());
5754        msg.push(0);
5755        msg.extend_from_slice(value.as_bytes());
5756        msg
5757    }
5758
5759    /// Sign an arbitrary string `value` into a `Set-Cookie` header for `name`,
5760    /// HMAC-SHA256 over `name || 0x00 || value`: `name=<b64url(value)>.<sig>`. The
5761    /// generic form behind both the session cookie and the short-lived invite
5762    /// cookie; domain-separating by name keeps a signature valid only for the
5763    /// cookie it was minted for.
5764    pub fn sign_value(name: &str, value: &str, secret: &str, max_age_secs: i64) -> String {
5765        let sig = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(name, value));
5766        let b64 = b64url_encode(value.as_bytes());
5767        format!(
5768            "{name}={b64}.{sig}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age={max_age_secs}"
5769        )
5770    }
5771
5772    /// Verify + read a value out of the named signed cookie (`None` on absent /
5773    /// tampered / forged / cross-cookie). The generic form behind both readers.
5774    pub fn verify_value(headers: &HeaderMap, name: &str, secret: &str) -> Option<String> {
5775        let raw = cookie_value(headers, name)?;
5776        let (b64, sig) = raw.split_once('.')?;
5777        let bytes = b64url_decode(b64)?;
5778        let value = String::from_utf8(bytes).ok()?;
5779        let expected = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(name, &value));
5780        if constant_time_eq(expected.as_bytes(), sig.as_bytes()) {
5781            Some(value)
5782        } else {
5783            None
5784        }
5785    }
5786
5787    /// Sign an arbitrary `value` into an opaque, URL-safe token string
5788    /// `b64url(value).<sig>` (HMAC-SHA256 over `label || 0x00 || value`). Unlike
5789    /// [`sign_value`] this is NOT a `Set-Cookie` header — it's a bare token for a
5790    /// URL query param (the bot's claim link). `label` domain-separates it from
5791    /// the cookies so a token can't be replayed as a cookie value.
5792    pub fn sign_token(label: &str, value: &str, secret: &str) -> String {
5793        let sig = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(label, value));
5794        let b64 = b64url_encode(value.as_bytes());
5795        format!("{b64}.{sig}")
5796    }
5797
5798    /// Verify a token minted by [`sign_token`] and return the wrapped value
5799    /// (`None` on tamper / forge / malformed). Constant-time signature compare.
5800    pub fn verify_token(label: &str, token: &str, secret: &str) -> Option<String> {
5801        let (b64, sig) = token.split_once('.')?;
5802        let bytes = b64url_decode(b64)?;
5803        let value = String::from_utf8(bytes).ok()?;
5804        let expected = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(label, &value));
5805        if constant_time_eq(expected.as_bytes(), sig.as_bytes()) {
5806            Some(value)
5807        } else {
5808            None
5809        }
5810    }
5811
5812    /// Pull one cookie value out of the `Cookie` request header.
5813    fn cookie_value(headers: &HeaderMap, name: &str) -> Option<String> {
5814        let header = headers.get(axum::http::header::COOKIE)?.to_str().ok()?;
5815        for part in header.split(';') {
5816            let part = part.trim();
5817            if let Some((k, v)) = part.split_once('=') {
5818                if k == name {
5819                    return Some(v.to_string());
5820                }
5821            }
5822        }
5823        None
5824    }
5825
5826    /// Constant-time byte comparison (avoid signature-timing leaks). Public
5827    /// within the module so the bot-secret bearer check reuses the exact same
5828    /// comparator as the cookie/token HMAC checks (one implementation to audit).
5829    pub fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
5830        if a.len() != b.len() {
5831            return false;
5832        }
5833        let mut diff = 0u8;
5834        for (x, y) in a.iter().zip(b.iter()) {
5835            diff |= x ^ y;
5836        }
5837        diff == 0
5838    }
5839
5840    // -- URL-safe base64 (no padding), std-only --------------------------------
5841
5842    const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
5843
5844    fn b64url_encode(input: &[u8]) -> String {
5845        let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
5846        for chunk in input.chunks(3) {
5847            let b = [
5848                chunk[0],
5849                *chunk.get(1).unwrap_or(&0),
5850                *chunk.get(2).unwrap_or(&0),
5851            ];
5852            let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | (b[2] as u32);
5853            out.push(B64[((n >> 18) & 63) as usize] as char);
5854            out.push(B64[((n >> 12) & 63) as usize] as char);
5855            if chunk.len() > 1 {
5856                out.push(B64[((n >> 6) & 63) as usize] as char);
5857            }
5858            if chunk.len() > 2 {
5859                out.push(B64[(n & 63) as usize] as char);
5860            }
5861        }
5862        out
5863    }
5864
5865    fn b64url_decode(input: &str) -> Option<Vec<u8>> {
5866        fn val(c: u8) -> Option<u32> {
5867            match c {
5868                b'A'..=b'Z' => Some((c - b'A') as u32),
5869                b'a'..=b'z' => Some((c - b'a' + 26) as u32),
5870                b'0'..=b'9' => Some((c - b'0' + 52) as u32),
5871                b'-' => Some(62),
5872                b'_' => Some(63),
5873                _ => None,
5874            }
5875        }
5876        let bytes = input.as_bytes();
5877        let mut out = Vec::with_capacity(input.len() / 4 * 3 + 2);
5878        for chunk in bytes.chunks(4) {
5879            let mut n = 0u32;
5880            let mut valid = 0;
5881            for (i, &c) in chunk.iter().enumerate() {
5882                n |= val(c)? << (18 - 6 * i);
5883                valid += 1;
5884            }
5885            out.push((n >> 16) as u8);
5886            if valid > 2 {
5887                out.push((n >> 8) as u8);
5888            }
5889            if valid > 3 {
5890                out.push(n as u8);
5891            }
5892        }
5893        Some(out)
5894    }
5895
5896    // -- HMAC-SHA256, std-only -------------------------------------------------
5897
5898    /// HMAC-SHA256(key, msg) as lowercase hex.
5899    fn hmac_sha256_hex(key: &[u8], msg: &[u8]) -> String {
5900        const BLOCK: usize = 64;
5901        let mut k = [0u8; BLOCK];
5902        if key.len() > BLOCK {
5903            let d = sha256(key);
5904            k[..32].copy_from_slice(&d);
5905        } else {
5906            k[..key.len()].copy_from_slice(key);
5907        }
5908        let mut ipad = [0x36u8; BLOCK];
5909        let mut opad = [0x5cu8; BLOCK];
5910        for i in 0..BLOCK {
5911            ipad[i] ^= k[i];
5912            opad[i] ^= k[i];
5913        }
5914        let mut inner = Vec::with_capacity(BLOCK + msg.len());
5915        inner.extend_from_slice(&ipad);
5916        inner.extend_from_slice(msg);
5917        let inner_hash = sha256(&inner);
5918        let mut outer = Vec::with_capacity(BLOCK + 32);
5919        outer.extend_from_slice(&opad);
5920        outer.extend_from_slice(&inner_hash);
5921        let mac = sha256(&outer);
5922        let mut hex = String::with_capacity(64);
5923        for b in mac {
5924            hex.push_str(&format!("{b:02x}"));
5925        }
5926        hex
5927    }
5928
5929    /// SHA-256 (FIPS 180-4), std-only.
5930    fn sha256(data: &[u8]) -> [u8; 32] {
5931        const K: [u32; 64] = [
5932            0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4,
5933            0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe,
5934            0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f,
5935            0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7,
5936            0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc,
5937            0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b,
5938            0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116,
5939            0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
5940            0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7,
5941            0xc67178f2,
5942        ];
5943        let mut h: [u32; 8] = [
5944            0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab,
5945            0x5be0cd19,
5946        ];
5947
5948        let bit_len = (data.len() as u64) * 8;
5949        let mut msg = data.to_vec();
5950        msg.push(0x80);
5951        while msg.len() % 64 != 56 {
5952            msg.push(0);
5953        }
5954        msg.extend_from_slice(&bit_len.to_be_bytes());
5955
5956        for block in msg.chunks(64) {
5957            let mut w = [0u32; 64];
5958            for i in 0..16 {
5959                w[i] = u32::from_be_bytes([
5960                    block[i * 4],
5961                    block[i * 4 + 1],
5962                    block[i * 4 + 2],
5963                    block[i * 4 + 3],
5964                ]);
5965            }
5966            for i in 16..64 {
5967                let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3);
5968                let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10);
5969                w[i] = w[i - 16]
5970                    .wrapping_add(s0)
5971                    .wrapping_add(w[i - 7])
5972                    .wrapping_add(s1);
5973            }
5974            let mut a = h;
5975            for i in 0..64 {
5976                let s1 = a[4].rotate_right(6) ^ a[4].rotate_right(11) ^ a[4].rotate_right(25);
5977                let ch = (a[4] & a[5]) ^ ((!a[4]) & a[6]);
5978                let t1 = a[7]
5979                    .wrapping_add(s1)
5980                    .wrapping_add(ch)
5981                    .wrapping_add(K[i])
5982                    .wrapping_add(w[i]);
5983                let s0 = a[0].rotate_right(2) ^ a[0].rotate_right(13) ^ a[0].rotate_right(22);
5984                let maj = (a[0] & a[1]) ^ (a[0] & a[2]) ^ (a[1] & a[2]);
5985                let t2 = s0.wrapping_add(maj);
5986                a[7] = a[6];
5987                a[6] = a[5];
5988                a[5] = a[4];
5989                a[4] = a[3].wrapping_add(t1);
5990                a[3] = a[2];
5991                a[2] = a[1];
5992                a[1] = a[0];
5993                a[0] = t1.wrapping_add(t2);
5994            }
5995            for i in 0..8 {
5996                h[i] = h[i].wrapping_add(a[i]);
5997            }
5998        }
5999
6000        let mut out = [0u8; 32];
6001        for (i, word) in h.iter().enumerate() {
6002            out[i * 4..i * 4 + 4].copy_from_slice(&word.to_be_bytes());
6003        }
6004        out
6005    }
6006
6007    #[cfg(test)]
6008    mod tests {
6009        use super::*;
6010
6011        #[test]
6012        fn sha256_known_vector() {
6013            let d = sha256(b"abc");
6014            let hex: String = d.iter().map(|b| format!("{b:02x}")).collect();
6015            assert_eq!(
6016                hex,
6017                "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
6018            );
6019        }
6020
6021        #[test]
6022        fn hmac_known_vector() {
6023            let mac = hmac_sha256_hex(b"Jefe", b"what do ya want for nothing?");
6024            assert_eq!(
6025                mac,
6026                "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843"
6027            );
6028        }
6029
6030        #[test]
6031        fn sign_verify_round_trips() {
6032            let secret = "test-secret";
6033            let sid = "9f2c-opaque-session-id";
6034            let cookie = sign_session(sid, secret);
6035            let pair = cookie.split(';').next().unwrap().to_string();
6036            let mut headers = HeaderMap::new();
6037            headers.insert(axum::http::header::COOKIE, pair.parse().unwrap());
6038            assert_eq!(verify_session(&headers, secret).as_deref(), Some(sid));
6039            // Wrong secret → rejected (an attacker without the HMAC key can't forge).
6040            assert!(verify_session(&headers, "other-secret").is_none());
6041        }
6042
6043        #[test]
6044        fn forged_and_tampered_cookies_are_rejected() {
6045            let secret = "test-secret";
6046
6047            // 1. A fully forged cookie: attacker knows a victim's DID/sid but not
6048            //    the secret, so an arbitrary signature must not verify.
6049            let forged = format!(
6050                "{SESSION_COOKIE}={}.{}",
6051                b64url_encode(b"attacker-chosen-sid"),
6052                "deadbeef".repeat(8) // 64 hex chars, wrong sig
6053            );
6054            let mut headers = HeaderMap::new();
6055            headers.insert(axum::http::header::COOKIE, forged.parse().unwrap());
6056            assert!(verify_session(&headers, secret).is_none());
6057
6058            // 2. A tampered cookie: take a VALID cookie and mutate the sid while
6059            //    keeping the original signature — must not verify.
6060            let cookie = sign_session("real-sid", secret);
6061            let pair = cookie.split(';').next().unwrap();
6062            let (_b64, sig) = pair.split_once('=').unwrap().1.split_once('.').unwrap();
6063            let tampered = format!(
6064                "{SESSION_COOKIE}={}.{}",
6065                b64url_encode(b"different-sid"),
6066                sig
6067            );
6068            let mut headers2 = HeaderMap::new();
6069            headers2.insert(axum::http::header::COOKIE, tampered.parse().unwrap());
6070            assert!(verify_session(&headers2, secret).is_none());
6071        }
6072
6073        #[test]
6074        fn b64url_round_trips() {
6075            for s in ["did:plc:abc", "", "a", "ab", "abc", "abcd"] {
6076                let enc = b64url_encode(s.as_bytes());
6077                assert_eq!(b64url_decode(&enc).unwrap(), s.as_bytes());
6078            }
6079        }
6080    }
6081}
6082
6083// ---------------------------------------------------------------------------
6084// Small store helpers local to the web layer
6085// ---------------------------------------------------------------------------
6086
6087/// Fetch a single cached entry by id — SCOPED to `did`'s subscriptions.
6088///
6089/// Returns `None` (→ 404 at the handler) if the entry does not exist OR if
6090/// `did` does not subscribe to its feed. This is the per-DID read gate for the
6091/// `GET /entries/:id` reader and the htmx row rebuild: the shared cache is
6092/// deduped by URL, but no DID can read another DID's cached article.
6093///
6094/// **The only `SELECT e.*` left, and deliberately so.** This is the one surface
6095/// that renders `content_html`, and it fetches exactly one row. The list views
6096/// go through [`store::list_entries`], which is both paged and body-free — see
6097/// [`store::EntryListRow`] for why they had to stop sharing this projection.
6098async fn get_entry_by_id(
6099    pool: &store::Pool,
6100    did: &str,
6101    id: i64,
6102) -> anyhow::Result<Option<store::Entry>> {
6103    let entry = sqlx::query_as::<_, store::Entry>(
6104        r#"
6105        SELECT e.* FROM entries e
6106        WHERE e.id = ?2
6107          AND EXISTS (
6108              SELECT 1 FROM sub_ref sr
6109              WHERE sr.did = ?1 AND sr.feed_id = e.feed_id
6110          )
6111        "#,
6112    )
6113    .bind(did)
6114    .bind(id)
6115    .fetch_optional(pool)
6116    .await?;
6117    Ok(entry)
6118}
6119
6120/// Whether `entry_id` is marked read for `did` (absent state row = unread).
6121async fn entry_is_read(pool: &store::Pool, did: &str, entry_id: i64) -> anyhow::Result<bool> {
6122    let read: Option<bool> =
6123        sqlx::query_scalar("SELECT read FROM entry_state WHERE did = ?1 AND entry_id = ?2")
6124            .bind(did)
6125            .bind(entry_id)
6126            .fetch_optional(pool)
6127            .await?
6128            .flatten();
6129    Ok(read.unwrap_or(false))
6130}
6131
6132/// Whether `entry_id` is starred for `did` (absent state row = not starred).
6133async fn entry_is_starred(pool: &store::Pool, did: &str, entry_id: i64) -> anyhow::Result<bool> {
6134    let starred: Option<bool> =
6135        sqlx::query_scalar("SELECT starred FROM entry_state WHERE did = ?1 AND entry_id = ?2")
6136            .bind(did)
6137            .bind(entry_id)
6138            .fetch_optional(pool)
6139            .await?
6140            .flatten();
6141    Ok(starred.unwrap_or(false))
6142}
6143
6144/// Feed display title for one entry's feed id (via a single lookup).
6145async fn feed_title_by_entry(pool: &store::Pool, feed_id: i64) -> String {
6146    match sqlx::query_as::<_, store::Feed>("SELECT * FROM feeds WHERE id = ?1")
6147        .bind(feed_id)
6148        .fetch_optional(pool)
6149        .await
6150    {
6151        Ok(Some(f)) => display_title(f.title.as_deref(), &f.url),
6152        _ => String::new(),
6153    }
6154}
6155
6156/// Rebuild an [`EntryRow`] for an htmx swap after a read/star toggle. `read` may
6157/// be forced (mark-read path) or looked up (`None` — star path).
6158async fn build_entry_row(
6159    pool: &store::Pool,
6160    did: &str,
6161    id: i64,
6162    read: Option<bool>,
6163) -> anyhow::Result<Option<EntryRow>> {
6164    let entry = match get_entry_by_id(pool, did, id).await? {
6165        Some(e) => e,
6166        None => return Ok(None),
6167    };
6168    let read = match read {
6169        Some(r) => r,
6170        None => entry_is_read(pool, did, id).await?,
6171    };
6172    let starred = entry_is_starred(pool, did, id).await?;
6173    Ok(Some(EntryRow {
6174        id: entry.id,
6175        title: entry
6176            .title
6177            .clone()
6178            .filter(|t| !t.trim().is_empty())
6179            .unwrap_or_else(|| "(untitled)".to_string()),
6180        feed_title: feed_title_by_entry(pool, entry.feed_id).await,
6181        published: display_date(entry.published.as_deref()),
6182        read,
6183        starred,
6184        link: SafeLink::entry(id, ""),
6185        cached: true,
6186        rkey: String::new(),
6187    }))
6188}
6189
6190/// RFC3339 "now" (UTC) — shared by handlers that stamp/compare timestamps.
6191fn now_rfc3339() -> String {
6192    chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
6193}
6194
6195#[cfg(test)]
6196mod tests {
6197    use super::*;
6198
6199    #[test]
6200    fn qenc_encodes_reserved() {
6201        assert_eq!(qenc("a b"), "a%20b");
6202        assert_eq!(
6203            qenc("https://example.com/feed.xml"),
6204            "https%3A%2F%2Fexample.com%2Ffeed.xml"
6205        );
6206        assert_eq!(
6207            qenc("at://did:plc:x/c/r"),
6208            "at%3A%2F%2Fdid%3Aplc%3Ax%2Fc%2Fr"
6209        );
6210        // Unreserved chars pass through untouched.
6211        assert_eq!(qenc("A-Za-z0-9-_.~"), "A-Za-z0-9-_.~");
6212    }
6213
6214    #[test]
6215    fn folder_uri_shape() {
6216        assert_eq!(
6217            folder_uri("did:plc:abc", "3kfolder"),
6218            "at://did:plc:abc/community.lexicon.rss.folder/3kfolder"
6219        );
6220    }
6221
6222    // -- public-feeds-only: private/paid feeds are refused --------------------
6223
6224    #[test]
6225    fn private_feeds_are_classified_private_across_providers() {
6226        // The add + OPML paths both gate on this classifier; assert it flags a
6227        // spread of paid providers (newsletters + private podcasts) and the
6228        // generic credential-in-URL shapes.
6229        for url in [
6230            "https://author.substack.com/feed/private/deadbeefcafe1234",
6231            "https://www.patreon.com/rss/author?auth=Zm9vYmFyc2VjcmV0dG9rZW4",
6232            "https://blog.ghost.io/rss/?uuid=1f2e3d4c-5b6a-7089-90ab-cdef01234567",
6233            "https://feeds.supportingcast.fm/show/abcdef0123456789abcdef01",
6234            "https://example.com/feed?token=Zm9vYmFyc2VjcmV0",
6235            "https://user:pass@example.com/feed",
6236        ] {
6237            assert!(
6238                feed::classify_feed_privacy(url).is_private(),
6239                "expected private: {url}"
6240            );
6241        }
6242    }
6243
6244    #[test]
6245    fn public_feeds_stay_public() {
6246        for url in [
6247            "https://author.substack.com/feed",
6248            "https://wordpress.example.com/feed/",
6249            "https://example.com/rss.xml",
6250            "https://example.org/atom.xml",
6251            // YouTube channel/playlist RSS is fully public — must not false-block.
6252            "https://www.youtube.com/feeds/videos.xml?channel_id=UC-lHJZR3Gqxm24_Vd_AJ5Yw",
6253            "https://www.youtube.com/feeds/videos.xml?playlist_id=PLFgquLnL59alCl_2TQvOiD5Vgm1",
6254        ] {
6255            assert!(
6256                !feed::classify_feed_privacy(url).is_private(),
6257                "expected public: {url}"
6258            );
6259        }
6260    }
6261
6262    #[test]
6263    fn private_feed_label_is_public_safe_host_only() {
6264        // The OPML skip report must never echo the secret path/query, only the host.
6265        let label =
6266            private_feed_label("https://author.substack.com/feed/private/deadbeefcafe1234token");
6267        assert_eq!(label, "author.substack.com");
6268        assert!(!label.contains("deadbeefcafe1234token"));
6269        assert!(!label.contains("/private/"));
6270        // An unparseable URL degrades to a generic label.
6271        assert_eq!(private_feed_label("not a url"), "a private feed");
6272    }
6273
6274    #[test]
6275    fn refusal_message_promises_nothing_stored() {
6276        assert!(PRIVATE_FEED_REFUSAL.contains("not saved or sent anywhere"));
6277        assert!(PRIVATE_FEED_REFUSAL.contains("public feeds"));
6278    }
6279
6280    #[test]
6281    fn scope_query_preserves_context() {
6282        let q = EntryQuery {
6283            feed: Some("https://example.com/feed.xml".to_string()),
6284            folder: None,
6285            view: Some("all".to_string()),
6286        };
6287        let s = scope_query(&q);
6288        assert!(s.contains("feed=https%3A%2F%2Fexample.com%2Ffeed.xml"));
6289        assert!(s.contains("view=all"));
6290
6291        // Default view is omitted.
6292        let q2 = EntryQuery {
6293            feed: None,
6294            folder: None,
6295            view: Some("unread".to_string()),
6296        };
6297        assert_eq!(scope_query(&q2), "");
6298    }
6299
6300    // -- closed-beta invite gate + rate-limit + cache-control ------------------
6301
6302    use axum::body::Body;
6303    use axum::http::Request;
6304    use tower::ServiceExt; // for `oneshot`
6305
6306    /// Build an [`AppState`] over a fresh in-memory DB, seeding the given admin
6307    /// DIDs (via ALLOWED_DIDS → ensure_seed) and a fixed cookie secret so tests
6308    /// can forge matching cookies.
6309    async fn test_state(allowed: &[&str]) -> AppState {
6310        let db = store::init_url("sqlite::memory:").await.unwrap();
6311        let dids: Vec<String> = allowed.iter().map(|s| s.to_string()).collect();
6312        store::ensure_seed(&db, &dids).await.unwrap();
6313        let config = Config {
6314            allowed_dids: dids,
6315            cookie_secret: "test-cookie-secret-000".to_string(),
6316            beta_cap: 3,
6317            ..Config::default()
6318        };
6319        AppState::new(config, db).unwrap()
6320    }
6321
6322    /// A `Cookie` header carrying a valid signed session for `sid` (the sid is
6323    /// looked up in the registry, so create the session first).
6324    fn session_cookie(state: &AppState, did: &str, handle: Option<&str>) -> String {
6325        let sid = state.sessions.create(Session {
6326            did: did.to_string(),
6327            handle: handle.map(str::to_string),
6328        });
6329        let sc = cookie::sign_session(&sid, &state.config.cookie_secret);
6330        sc.split(';').next().unwrap().to_string()
6331    }
6332
6333    /// The bucket map is bounded by COUNT, not only by idle time. An hour is a
6334    /// long time to accept distinct source IPs on two unauthenticated guarded
6335    /// routes.
6336    #[test]
6337    fn the_rate_limit_map_is_bounded() {
6338        let rl = RateLimiter::shared();
6339        let now = Instant::now();
6340        for i in 0..(MAX_RATE_BUCKETS + 2_000) {
6341            // Distinct IPv6 addresses, all "seen" at increasing times so the LRU
6342            // ordering below is well-defined.
6343            let ip: IpAddr = format!("2001:db8::{i:x}").parse().unwrap();
6344            rl.check_at(ip, now + Duration::from_millis(i as u64));
6345        }
6346        let len = rl.inner.lock().unwrap().buckets.len();
6347        assert!(
6348            len <= MAX_RATE_BUCKETS,
6349            "the rate-limit map grew to {len}, past its {MAX_RATE_BUCKETS} cap"
6350        );
6351    }
6352
6353    /// Eviction must not hand a throttled attacker a fresh burst.
6354    ///
6355    /// The bound is LRU, so the one bucket an attacker can never evict is their
6356    /// own — it is the most recently touched thing in the map. If this inverted,
6357    /// the size cap would become a rate-limit bypass: spray addresses until the
6358    /// map overflows, then resume.
6359    #[test]
6360    fn flooding_the_map_does_not_reset_the_flooders_own_bucket() {
6361        let rl = RateLimiter::shared();
6362        let base = Instant::now();
6363        let attacker: IpAddr = "203.0.113.7".parse().unwrap();
6364        // Nanosecond steps: enough to keep the LRU ordering strictly increasing,
6365        // far too little for `RATE_REFILL_PER_SEC` to hand back a token. A
6366        // millisecond step made the whole flood take a second, and the refill —
6367        // working correctly — then looked exactly like an eviction bypass.
6368        let at = |n: u64| base + Duration::from_nanos(n);
6369
6370        // Spend the burst. `RATE_BURST` allowed, then refused.
6371        for i in 0..(RATE_BURST as u64) {
6372            assert!(rl.check_at(attacker, at(i)));
6373        }
6374        assert!(
6375            !rl.check_at(attacker, at(RATE_BURST as u64)),
6376            "burst was not exhausted; the rest of this test proves nothing"
6377        );
6378
6379        // Now overflow the map from other addresses, interleaving the attacker
6380        // so their bucket stays hot — the realistic shape of the attack.
6381        for i in 0..(MAX_RATE_BUCKETS + 2_000) {
6382            let t = at(100 + i as u64 * 2);
6383            let ip: IpAddr = format!("2001:db8:1::{i:x}").parse().unwrap();
6384            rl.check_at(ip, t);
6385            assert!(
6386                !rl.check_at(attacker, t),
6387                "the attacker got a token back after evictions at i={i}"
6388            );
6389        }
6390    }
6391
6392    /// The idle sweep is amortised, not per-request. It used to be an O(n) scan
6393    /// of the whole map on every guarded request, on one shared core.
6394    #[test]
6395    fn the_idle_sweep_does_not_run_on_every_request() {
6396        let rl = RateLimiter::shared();
6397        let start = Instant::now();
6398        let a: IpAddr = "198.51.100.1".parse().unwrap();
6399        let b: IpAddr = "198.51.100.2".parse().unwrap();
6400
6401        rl.check_at(a, start);
6402        // `b` arrives an hour later: `a` is now idle past `RATE_IDLE_EVICT`, but
6403        // the sweep interval has elapsed too, so this request does sweep it.
6404        rl.check_at(b, start + RATE_IDLE_EVICT + Duration::from_secs(1));
6405        assert!(
6406            !rl.inner.lock().unwrap().buckets.contains_key(&a),
6407            "an idle bucket survived a sweep that was due"
6408        );
6409
6410        // A second request moments later must NOT re-sweep — `b` is still there,
6411        // and the recorded sweep time must not have moved.
6412        let before = rl.inner.lock().unwrap().last_sweep;
6413        rl.check_at(b, start + RATE_IDLE_EVICT + Duration::from_secs(2));
6414        assert_eq!(
6415            rl.inner.lock().unwrap().last_sweep,
6416            before,
6417            "the sweep ran again within the interval"
6418        );
6419    }
6420
6421    #[test]
6422    fn rate_limited_paths_match_expected() {
6423        use axum::http::Method;
6424        assert!(is_rate_limited_path("/login", &Method::GET));
6425        assert!(is_rate_limited_path("/login", &Method::POST));
6426        assert!(is_rate_limited_path("/beta/redeem", &Method::POST));
6427        assert!(is_rate_limited_path("/subscriptions", &Method::POST));
6428        assert!(is_rate_limited_path("/opml", &Method::POST));
6429        assert!(is_rate_limited_path("/read-all", &Method::POST));
6430        assert!(is_rate_limited_path("/admin/invites", &Method::POST));
6431        assert!(is_rate_limited_path("/entries/42/read", &Method::POST));
6432        assert!(is_rate_limited_path("/entries/42/star", &Method::POST));
6433        // Read-only navigation is NOT limited.
6434        assert!(!is_rate_limited_path("/", &Method::GET));
6435        assert!(!is_rate_limited_path("/about", &Method::GET));
6436        assert!(!is_rate_limited_path("/entries/42", &Method::GET));
6437        assert!(!is_rate_limited_path("/login", &Method::HEAD));
6438    }
6439
6440    #[test]
6441    fn rate_limiter_allows_burst_then_429s() {
6442        let rl = RateLimiter::shared();
6443        let ip: IpAddr = "203.0.113.7".parse().unwrap();
6444        // The full burst passes.
6445        for _ in 0..(RATE_BURST as usize) {
6446            assert!(rl.check(ip));
6447        }
6448        // The next one (no time elapsed → no refill) is rejected.
6449        assert!(!rl.check(ip));
6450        // A different IP has its own bucket.
6451        let ip2: IpAddr = "203.0.113.8".parse().unwrap();
6452        assert!(rl.check(ip2));
6453    }
6454
6455    #[test]
6456    fn client_ip_ignores_spoofed_xff_without_trusted_header() {
6457        // With NO trusted header configured, a client-supplied X-Forwarded-For
6458        // must be ignored entirely — the limiter keys on the real socket peer,
6459        // so an attacker can't mint a fresh bucket per forged XFF value.
6460        let mut h = HeaderMap::new();
6461        h.insert("x-forwarded-for", "198.51.100.9, 10.0.0.1".parse().unwrap());
6462        let sock: SocketAddr = "203.0.113.55:1234".parse().unwrap();
6463        assert_eq!(
6464            client_ip(&h, Some(&sock), None),
6465            Some("203.0.113.55".parse().unwrap()),
6466            "spoofed XFF must not override the socket peer"
6467        );
6468    }
6469
6470    #[test]
6471    fn client_ip_uses_trusted_header_last_hop() {
6472        // With a trusted proxy header configured, the client IP comes from THAT
6473        // header (the proxy overwrites any client copy). On a comma list we take
6474        // the RIGHT-most hop — the one the trusted proxy appended — so a
6475        // client-forged left-most value is ignored.
6476        let sock: SocketAddr = "10.0.0.1:1234".parse().unwrap();
6477
6478        let mut h = HeaderMap::new();
6479        h.insert("fly-client-ip", "198.51.100.9".parse().unwrap());
6480        assert_eq!(
6481            client_ip(&h, Some(&sock), Some("fly-client-ip")),
6482            Some("198.51.100.9".parse().unwrap())
6483        );
6484
6485        // Attacker prepends a forged hop; the trusted proxy appends the real one.
6486        let mut h2 = HeaderMap::new();
6487        h2.insert("x-forwarded-for", "1.2.3.4, 198.51.100.9".parse().unwrap());
6488        assert_eq!(
6489            client_ip(&h2, Some(&sock), Some("x-forwarded-for")),
6490            Some("198.51.100.9".parse().unwrap()),
6491            "must take the right-most (trusted) hop, not the forged left-most"
6492        );
6493
6494        // Trusted header absent → fall back to the socket peer.
6495        let h3 = HeaderMap::new();
6496        assert_eq!(
6497            client_ip(&h3, Some(&sock), Some("fly-client-ip")),
6498            Some("10.0.0.1".parse().unwrap())
6499        );
6500    }
6501
6502    #[test]
6503    fn invite_cookie_round_trips_and_rejects_tamper() {
6504        let secret = "test-cookie-secret-000";
6505        let sc = sign_invite("FEATHER-ABCDWXYZ", secret);
6506        let pair = sc.split(';').next().unwrap();
6507        let mut h = HeaderMap::new();
6508        h.insert(header::COOKIE, pair.parse().unwrap());
6509        assert_eq!(
6510            invite_cookie_code(&h, secret).as_deref(),
6511            Some("FEATHER-ABCDWXYZ")
6512        );
6513        // Wrong secret → rejected.
6514        assert!(invite_cookie_code(&h, "other").is_none());
6515    }
6516
6517    #[tokio::test]
6518    async fn preflight_valid_expired_and_full() {
6519        let state = test_state(&["did:plc:admin"]).await;
6520        // A minted, active code preflights OK.
6521        let code = store::mint_code(&state.db, "did:plc:admin", 3600)
6522            .await
6523            .unwrap();
6524        assert!(preflight_code(&state, &code).await.is_ok());
6525
6526        // A code whose expiry is in the past preflights as Expired. (mint_code
6527        // clamps negative ttl to 0, so back-date the row directly for a
6528        // deterministic past expiry.)
6529        let expired = store::mint_code(&state.db, "did:plc:admin", 3600)
6530            .await
6531            .unwrap();
6532        sqlx::query("UPDATE invite_codes SET expires_at = ?1 WHERE code = ?2")
6533            .bind(chrono::Utc::now().timestamp() - 3600)
6534            .bind(&expired)
6535            .execute(&state.db)
6536            .await
6537            .unwrap();
6538        assert_eq!(
6539            preflight_code(&state, &expired).await,
6540            Err(store::RedeemError::Expired)
6541        );
6542
6543        // Unknown code → NotFound.
6544        assert_eq!(
6545            preflight_code(&state, "FEATHER-NOPENOPE").await,
6546            Err(store::RedeemError::NotFound)
6547        );
6548
6549        // Fill to cap (cap=3; the admin seed already took 1 seat) then preflight
6550        // must report CapacityFull.
6551        store::grant_access(&state.db, "did:plc:b", None, "admin", None)
6552            .await
6553            .unwrap();
6554        store::grant_access(&state.db, "did:plc:c", None, "admin", None)
6555            .await
6556            .unwrap();
6557        assert_eq!(store::count_beta_access(&state.db).await.unwrap(), 3);
6558        assert_eq!(
6559            preflight_code(&state, &code).await,
6560            Err(store::RedeemError::CapacityFull)
6561        );
6562    }
6563
6564    // -- Bot claim link + shared-secret mint ---------------------------------
6565
6566    /// A test state with a configured bot secret (so `/bot/claims` is live).
6567    async fn bot_state(bot_secret: &str) -> AppState {
6568        let db = store::init_url("sqlite::memory:").await.unwrap();
6569        store::ensure_seed(&db, &["did:plc:admin".to_string()])
6570            .await
6571            .unwrap();
6572        let config = Config {
6573            allowed_dids: vec!["did:plc:admin".to_string()],
6574            cookie_secret: "test-cookie-secret-000".to_string(),
6575            beta_cap: 3,
6576            bot_secret: Some(bot_secret.to_string()),
6577            public_url: "https://feather-reader.com".to_string(),
6578            ..Config::default()
6579        };
6580        AppState::new(config, db).unwrap()
6581    }
6582
6583    #[test]
6584    fn claim_token_round_trips_and_rejects_tamper() {
6585        let secret = "test-cookie-secret-000";
6586        let token = sign_claim_token("FEATHER-ABCDWXYZ", secret);
6587        // No cookie framing — a bare URL-safe token.
6588        assert!(!token.contains(';'));
6589        assert_eq!(
6590            claim_token_code(&token, secret).as_deref(),
6591            Some("FEATHER-ABCDWXYZ")
6592        );
6593        // Wrong secret → rejected.
6594        assert!(claim_token_code(&token, "other").is_none());
6595        // Tampered token → rejected.
6596        let mut bad = token.clone();
6597        bad.push('x');
6598        assert!(claim_token_code(&bad, secret).is_none());
6599        // The token is NOT confidential: it is `b64url(code).<sig>`, so the code is
6600        // only base64-obscured (not verbatim, but TRIVIALLY decodable — anyone can
6601        // recover it WITHOUT the secret). The security is single-use + HMAC
6602        // integrity + rate-limit, not secrecy of the code. Assert the code half is
6603        // publicly decodable (a plain base64url decode, no secret involved).
6604        let (b64, _sig) = token.split_once('.').expect("token is b64.sig");
6605        assert_eq!(
6606            test_b64url_decode(b64).as_deref(),
6607            Some("FEATHER-ABCDWXYZ".as_bytes()),
6608            "the code half of the token is plain base64url, decodable by anyone"
6609        );
6610    }
6611
6612    /// Minimal URL-safe base64 (no padding) decoder for the test above, proving the
6613    /// claim token's code half needs NO secret to recover (it is not confidential).
6614    fn test_b64url_decode(input: &str) -> Option<Vec<u8>> {
6615        fn val(c: u8) -> Option<u32> {
6616            match c {
6617                b'A'..=b'Z' => Some((c - b'A') as u32),
6618                b'a'..=b'z' => Some((c - b'a' + 26) as u32),
6619                b'0'..=b'9' => Some((c - b'0' + 52) as u32),
6620                b'-' => Some(62),
6621                b'_' => Some(63),
6622                _ => None,
6623            }
6624        }
6625        let mut out = Vec::with_capacity(input.len() / 4 * 3);
6626        for chunk in input.as_bytes().chunks(4) {
6627            let mut n = 0u32;
6628            let mut bits = 0;
6629            for &c in chunk {
6630                n = (n << 6) | val(c)?;
6631                bits += 6;
6632            }
6633            let bytes = bits / 8;
6634            n <<= 24 - bits;
6635            for i in 0..bytes {
6636                out.push((n >> (16 - i * 8)) as u8);
6637            }
6638        }
6639        Some(out)
6640    }
6641
6642    #[tokio::test]
6643    async fn bot_mint_then_claim_grants_a_seat() {
6644        let state = bot_state("bot-secret-abcdef").await;
6645        let app = router(state.clone());
6646
6647        // 1. Mint a claim via the shared-secret endpoint.
6648        let resp = app
6649            .clone()
6650            .oneshot(
6651                Request::builder()
6652                    .method("POST")
6653                    .uri("/bot/claims")
6654                    .header("x-bot-secret", "bot-secret-abcdef")
6655                    .body(Body::empty())
6656                    .unwrap(),
6657            )
6658            .await
6659            .unwrap();
6660        assert_eq!(resp.status(), StatusCode::OK);
6661        let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
6662            .await
6663            .unwrap();
6664        let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
6665        let token = json["token"].as_str().unwrap().to_string();
6666        let url = json["url"].as_str().unwrap();
6667        assert!(url.starts_with("https://feather-reader.com/claim?t="));
6668        // The raw code is returned for the bot's records but not embedded in url.
6669        assert!(json["code"].as_str().unwrap().starts_with("FEATHER-"));
6670        assert!(!url.contains("FEATHER-"));
6671
6672        // 2. Follow the claim link → reserves the invite cookie + redirects to /login.
6673        let resp = app
6674            .clone()
6675            .oneshot(
6676                Request::builder()
6677                    .method("GET")
6678                    .uri(format!("/claim?t={}", qenc(&token)))
6679                    .body(Body::empty())
6680                    .unwrap(),
6681            )
6682            .await
6683            .unwrap();
6684        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
6685        assert_eq!(resp.headers().get(header::LOCATION).unwrap(), "/login");
6686        let set_cookie = resp
6687            .headers()
6688            .get(header::SET_COOKIE)
6689            .unwrap()
6690            .to_str()
6691            .unwrap();
6692        assert!(set_cookie.starts_with(INVITE_COOKIE), "{set_cookie}");
6693
6694        // 3. The reserved cookie carries the same code the token wrapped, and
6695        //    redeeming it (the callback's machinery) grants a seat.
6696        let code = claim_token_code(&token, &state.config.cookie_secret).unwrap();
6697        let out = store::redeem_code(
6698            &state.db,
6699            &code,
6700            "did:plc:follower",
6701            None,
6702            state.config.beta_cap,
6703        )
6704        .await
6705        .unwrap();
6706        assert_eq!(out, Ok(()));
6707        assert!(store::has_beta_access(&state.db, "did:plc:follower")
6708            .await
6709            .unwrap());
6710    }
6711
6712    #[tokio::test]
6713    async fn claim_with_invalid_token_bounces() {
6714        let state = bot_state("bot-secret-abcdef").await;
6715        let app = router(state);
6716        let resp = app
6717            .oneshot(
6718                Request::builder()
6719                    .method("GET")
6720                    .uri("/claim?t=not-a-real-token")
6721                    .body(Body::empty())
6722                    .unwrap(),
6723            )
6724            .await
6725            .unwrap();
6726        // Renders the invite page (200), NOT a redirect to /login.
6727        assert_eq!(resp.status(), StatusCode::OK);
6728    }
6729
6730    #[tokio::test]
6731    async fn claim_with_used_token_is_refused() {
6732        let state = bot_state("bot-secret-abcdef").await;
6733        // Mint a code + wrap it, then redeem it out from under the token.
6734        let code = store::mint_code(&state.db, "did:plc:admin", 3600)
6735            .await
6736            .unwrap();
6737        let token = sign_claim_token(&code, &state.config.cookie_secret);
6738        store::redeem_code(
6739            &state.db,
6740            &code,
6741            "did:plc:someone",
6742            None,
6743            state.config.beta_cap,
6744        )
6745        .await
6746        .unwrap()
6747        .unwrap();
6748        let app = router(state);
6749        let resp = app
6750            .oneshot(
6751                Request::builder()
6752                    .method("GET")
6753                    .uri(format!("/claim?t={}", qenc(&token)))
6754                    .body(Body::empty())
6755                    .unwrap(),
6756            )
6757            .await
6758            .unwrap();
6759        // A used code → the invite page (AlreadyRedeemed), not a fresh reservation.
6760        assert_eq!(resp.status(), StatusCode::OK);
6761        assert!(resp.headers().get(header::SET_COOKIE).is_none());
6762    }
6763
6764    #[tokio::test]
6765    async fn bot_claims_rejects_bad_and_missing_secret() {
6766        let state = bot_state("bot-secret-abcdef").await;
6767        let app = router(state);
6768        // Wrong secret.
6769        let resp = app
6770            .clone()
6771            .oneshot(
6772                Request::builder()
6773                    .method("POST")
6774                    .uri("/bot/claims")
6775                    .header("x-bot-secret", "wrong")
6776                    .body(Body::empty())
6777                    .unwrap(),
6778            )
6779            .await
6780            .unwrap();
6781        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
6782        // Missing secret.
6783        let resp = app
6784            .oneshot(
6785                Request::builder()
6786                    .method("POST")
6787                    .uri("/bot/claims")
6788                    .body(Body::empty())
6789                    .unwrap(),
6790            )
6791            .await
6792            .unwrap();
6793        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
6794    }
6795
6796    #[tokio::test]
6797    async fn bot_claims_disabled_when_secret_unset() {
6798        // test_state configures NO bot secret → the endpoint is off (503).
6799        let state = test_state(&["did:plc:admin"]).await;
6800        let app = router(state);
6801        let resp = app
6802            .oneshot(
6803                Request::builder()
6804                    .method("POST")
6805                    .uri("/bot/claims")
6806                    .header("x-bot-secret", "anything")
6807                    .body(Body::empty())
6808                    .unwrap(),
6809            )
6810            .await
6811            .unwrap();
6812        assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
6813    }
6814
6815    #[tokio::test]
6816    async fn bot_claims_refuses_at_capacity() {
6817        let state = bot_state("bot-secret-abcdef").await;
6818        // cap=3, admin seed took 1 seat. Grant 2 more to fill it.
6819        store::grant_access(&state.db, "did:plc:b", None, "admin", None)
6820            .await
6821            .unwrap();
6822        store::grant_access(&state.db, "did:plc:c", None, "admin", None)
6823            .await
6824            .unwrap();
6825        assert_eq!(store::count_beta_access(&state.db).await.unwrap(), 3);
6826        let app = router(state);
6827        let resp = app
6828            .oneshot(
6829                Request::builder()
6830                    .method("POST")
6831                    .uri("/bot/claims")
6832                    .header("x-bot-secret", "bot-secret-abcdef")
6833                    .body(Body::empty())
6834                    .unwrap(),
6835            )
6836            .await
6837            .unwrap();
6838        assert_eq!(resp.status(), StatusCode::CONFLICT);
6839        let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
6840            .await
6841            .unwrap();
6842        assert!(String::from_utf8_lossy(&bytes).contains("full"));
6843    }
6844
6845    #[tokio::test]
6846    async fn bot_claims_counts_outstanding_codes_against_cap() {
6847        let state = bot_state("bot-secret-abcdef").await;
6848        // cap=3, admin seed = 1 seat. Two outstanding active codes = 3 committed.
6849        store::mint_code(&state.db, "did:plc:admin", 3600)
6850            .await
6851            .unwrap();
6852        store::mint_code(&state.db, "did:plc:admin", 3600)
6853            .await
6854            .unwrap();
6855        let app = router(state);
6856        let resp = app
6857            .oneshot(
6858                Request::builder()
6859                    .method("POST")
6860                    .uri("/bot/claims")
6861                    .header("x-bot-secret", "bot-secret-abcdef")
6862                    .body(Body::empty())
6863                    .unwrap(),
6864            )
6865            .await
6866            .unwrap();
6867        // 1 seat + 2 outstanding >= cap 3 → refused even though only 1 real seat used.
6868        assert_eq!(resp.status(), StatusCode::CONFLICT);
6869    }
6870
6871    /// POST /bot/claims with a JSON body carrying the follower DID.
6872    async fn post_bot_claim_for(
6873        app: &axum::Router,
6874        secret: &str,
6875        did: &str,
6876    ) -> (StatusCode, serde_json::Value) {
6877        let resp = app
6878            .clone()
6879            .oneshot(
6880                Request::builder()
6881                    .method("POST")
6882                    .uri("/bot/claims")
6883                    .header("x-bot-secret", secret)
6884                    .header("content-type", "application/json")
6885                    .body(Body::from(format!(
6886                        "{{\"did\":\"{did}\",\"handle\":\"who.test\"}}"
6887                    )))
6888                    .unwrap(),
6889            )
6890            .await
6891            .unwrap();
6892        let status = resp.status();
6893        let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
6894            .await
6895            .unwrap();
6896        let json = if bytes.is_empty() {
6897            serde_json::Value::Null
6898        } else {
6899            serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null)
6900        };
6901        (status, json)
6902    }
6903
6904    #[tokio::test]
6905    async fn bot_claims_returns_already_seated_for_a_member() {
6906        // A DID that already holds beta access must get `already_seated` with NO
6907        // code/url — the bot posts nothing. This is the server-side backstop that
6908        // survives a bot-host state loss (it would otherwise re-mint + re-post).
6909        let state = bot_state("bot-secret-abcdef").await;
6910        store::grant_access(&state.db, "did:plc:member", None, "admin", None)
6911            .await
6912            .unwrap();
6913        let app = router(state.clone());
6914        let (status, json) = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:member").await;
6915        assert_eq!(status, StatusCode::OK);
6916        assert_eq!(json["status"], "already_seated");
6917        assert_eq!(json["code"], "");
6918        assert_eq!(json["url"], "");
6919        // No new invite code was minted for the seated DID.
6920        assert!(store::find_active_code_for_did(&state.db, "did:plc:member")
6921            .await
6922            .unwrap()
6923            .is_none());
6924    }
6925
6926    #[tokio::test]
6927    async fn bot_claims_is_idempotent_per_did_returns_same_code() {
6928        // Two mint requests for the SAME follower DID must return the SAME code
6929        // (the app is authoritative), never a second one — so a bot-host state loss
6930        // re-requesting cannot double-mint or double-post.
6931        let state = bot_state("bot-secret-abcdef").await;
6932        let app = router(state.clone());
6933
6934        let (s1, j1) = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:follower1").await;
6935        assert_eq!(s1, StatusCode::OK);
6936        assert_eq!(j1["status"], "minted");
6937        let code1 = j1["code"].as_str().unwrap().to_string();
6938
6939        let (s2, j2) = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:follower1").await;
6940        assert_eq!(s2, StatusCode::OK);
6941        assert_eq!(j2["status"], "existing");
6942        assert_eq!(j2["code"].as_str().unwrap(), code1, "same code returned");
6943        assert_eq!(j2["url"], j1["url"], "same url returned");
6944
6945        // Exactly ONE active code exists for that DID.
6946        assert_eq!(store::count_active_codes(&state.db).await.unwrap(), 1);
6947    }
6948
6949    #[tokio::test]
6950    async fn bot_claims_records_intended_did_at_mint() {
6951        // A fresh mint records the follower DID so the lookup finds it.
6952        let state = bot_state("bot-secret-abcdef").await;
6953        let app = router(state.clone());
6954        let (status, json) =
6955            post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:follower2").await;
6956        assert_eq!(status, StatusCode::OK);
6957        let code = json["code"].as_str().unwrap();
6958        assert_eq!(
6959            store::find_active_code_for_did(&state.db, "did:plc:follower2")
6960                .await
6961                .unwrap()
6962                .as_deref(),
6963            Some(code)
6964        );
6965    }
6966
6967    #[tokio::test]
6968    async fn bot_claims_concurrent_same_did_never_double_mints() {
6969        // S4: two concurrent /bot/claims for ONE follower DID must not both mint an
6970        // active code. The dedupe check (3b) and the mint are separate statements,
6971        // so a race can slip both past 3b's `Ok(None)`; the partial unique index
6972        // then makes the loser's INSERT conflict, and the handler recovers by
6973        // returning the winner's code (status `existing`) rather than 500-ing.
6974        // Result: exactly ONE active code, and BOTH callers get a usable code.
6975        let state = bot_state("bot-secret-abcdef").await;
6976        let app = router(state.clone());
6977
6978        let a = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:racer");
6979        let b = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:racer");
6980        let ((sa, ja), (sb, jb)) = tokio::join!(a, b);
6981
6982        assert_eq!(sa, StatusCode::OK, "first response: {ja:?}");
6983        assert_eq!(sb, StatusCode::OK, "second response: {jb:?}");
6984
6985        // Exactly one active code for the DID — the whole point of the fix.
6986        assert_eq!(
6987            store::count_active_codes(&state.db).await.unwrap(),
6988            1,
6989            "concurrent mints must not create two active codes"
6990        );
6991
6992        // Both callers received the SAME (single) code, and neither got a 500.
6993        let ca = ja["code"].as_str().unwrap_or("");
6994        let cb = jb["code"].as_str().unwrap_or("");
6995        assert!(!ca.is_empty() && !cb.is_empty(), "both must return a code");
6996        assert_eq!(ca, cb, "both callers must get the one minted code");
6997        // One is `minted` (the winner), the other `minted` or `existing` depending
6998        // on interleaving — but never an error status.
6999        for st in [&ja["status"], &jb["status"]] {
7000            let s = st.as_str().unwrap_or("");
7001            assert!(s == "minted" || s == "existing", "unexpected status {s:?}");
7002        }
7003    }
7004
7005    #[tokio::test]
7006    async fn bot_claims_rejects_malformed_json_body() {
7007        let state = bot_state("bot-secret-abcdef").await;
7008        let app = router(state);
7009        let resp = app
7010            .oneshot(
7011                Request::builder()
7012                    .method("POST")
7013                    .uri("/bot/claims")
7014                    .header("x-bot-secret", "bot-secret-abcdef")
7015                    .header("content-type", "application/json")
7016                    .body(Body::from("{not json"))
7017                    .unwrap(),
7018            )
7019            .await
7020            .unwrap();
7021        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
7022    }
7023
7024    #[tokio::test]
7025    async fn favicon_ico_served_at_root() {
7026        // Browsers request bare /favicon.ico regardless of the <link rel="icon">
7027        // tags in <head>; the root route must serve the icon, not 404.
7028        let state = test_state(&[]).await;
7029        let app = router(state);
7030        let resp = app
7031            .oneshot(
7032                Request::builder()
7033                    .uri("/favicon.ico")
7034                    .body(Body::empty())
7035                    .unwrap(),
7036            )
7037            .await
7038            .unwrap();
7039        assert_eq!(resp.status(), StatusCode::OK);
7040        let ct = resp
7041            .headers()
7042            .get(header::CONTENT_TYPE)
7043            .unwrap()
7044            .to_str()
7045            .unwrap();
7046        assert!(
7047            ct.contains("icon") || ct.starts_with("image/"),
7048            "content-type = {ct}"
7049        );
7050    }
7051
7052    #[tokio::test]
7053    async fn login_without_invite_redirects_to_beta_redeem() {
7054        // No allow-list seed, no invite cookie: starting OAuth must be refused.
7055        let state = test_state(&[]).await;
7056        let app = router(state);
7057        let resp = app
7058            .oneshot(
7059                Request::builder()
7060                    .method("POST")
7061                    .uri("/login")
7062                    .header("content-type", "application/x-www-form-urlencoded")
7063                    .body(Body::from("handle=alice.bsky.social"))
7064                    .unwrap(),
7065            )
7066            .await
7067            .unwrap();
7068        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
7069        assert_eq!(
7070            resp.headers().get(header::LOCATION).unwrap(),
7071            "/beta/redeem"
7072        );
7073    }
7074
7075    #[tokio::test]
7076    async fn login_with_valid_invite_cookie_starts_oauth() {
7077        let state = test_state(&[]).await;
7078        let cookie = sign_invite("FEATHER-ABCDWXYZ", &state.config.cookie_secret);
7079        let cookie = cookie.split(';').next().unwrap().to_string();
7080        let app = router(state);
7081        let resp = app
7082            .oneshot(
7083                Request::builder()
7084                    .method("POST")
7085                    .uri("/login")
7086                    .header("content-type", "application/x-www-form-urlencoded")
7087                    .header(header::COOKIE, cookie)
7088                    .body(Body::from("handle=alice.bsky.social"))
7089                    .unwrap(),
7090            )
7091            .await
7092            .unwrap();
7093        // Redirects into the sidecar login (not to /beta/redeem).
7094        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
7095        let loc = resp
7096            .headers()
7097            .get(header::LOCATION)
7098            .unwrap()
7099            .to_str()
7100            .unwrap();
7101        assert!(loc.contains("/login"), "loc = {loc}");
7102        assert_ne!(loc, "/beta/redeem");
7103    }
7104
7105    /// A resolver that always fails — proves the fast paths short-circuit BEFORE
7106    /// any network resolution and that a resolution failure fails closed.
7107    async fn resolver_never(_handle: String) -> Option<String> {
7108        None
7109    }
7110
7111    /// A resolver that maps every handle to `did`.
7112    fn resolver_to(did: &'static str) -> impl FnOnce(String) -> std::future::Ready<Option<String>> {
7113        move |_handle| std::future::ready(Some(did.to_string()))
7114    }
7115
7116    /// Path 3: a cookie-less visitor whose submitted handle resolves to a DID
7117    /// that already holds a seat (the seeded-admin first-login case) passes the
7118    /// gate — no session cookie, no invite code.
7119    #[tokio::test]
7120    async fn may_start_oauth_honors_seat_via_resolved_handle() {
7121        // The seeded admin holds a seat (via ALLOWED_DIDS → ensure_seed) but has
7122        // no cookie on a fresh deploy.
7123        let state = test_state(&["did:plc:admin"]).await;
7124        let headers = HeaderMap::new();
7125        assert!(
7126            may_start_oauth_with(
7127                &state,
7128                &headers,
7129                "admin.example",
7130                resolver_to("did:plc:admin")
7131            )
7132            .await,
7133            "a handle resolving to a seated DID must pass the gate"
7134        );
7135    }
7136
7137    /// Path 3, negative: a handle that resolves to a DID with NO seat is bounced
7138    /// — the anti-abuse intent is preserved (resolution succeeds, seat check
7139    /// fails).
7140    #[tokio::test]
7141    async fn may_start_oauth_bounces_non_member_handle() {
7142        let state = test_state(&["did:plc:admin"]).await;
7143        let headers = HeaderMap::new();
7144        assert!(
7145            !may_start_oauth_with(
7146                &state,
7147                &headers,
7148                "rando.example",
7149                resolver_to("did:plc:rando")
7150            )
7151            .await,
7152            "a resolved DID with no seat must be bounced"
7153        );
7154    }
7155
7156    /// Fail-closed: an unresolvable/malformed handle (resolver returns `None`)
7157    /// bounces gracefully — no panic, no handshake.
7158    #[tokio::test]
7159    async fn may_start_oauth_fails_closed_on_unresolvable_handle() {
7160        let state = test_state(&["did:plc:admin"]).await;
7161        let headers = HeaderMap::new();
7162        assert!(
7163            !may_start_oauth_with(&state, &headers, "not a handle", resolver_never).await,
7164            "an unresolvable handle must fail closed"
7165        );
7166    }
7167
7168    /// The session-cookie fast path admits a seated member WITHOUT calling the
7169    /// resolver (proven by injecting `resolver_never`, which would otherwise
7170    /// bounce).
7171    #[tokio::test]
7172    async fn may_start_oauth_session_cookie_shortcircuits_resolution() {
7173        let state = test_state(&[]).await;
7174        let did = "did:plc:member";
7175        store::grant_access(&state.db, did, Some("member.example"), "test", None)
7176            .await
7177            .unwrap();
7178        let cookie = session_cookie(&state, did, Some("member.example"));
7179        let mut headers = HeaderMap::new();
7180        headers.insert(header::COOKIE, cookie.parse().unwrap());
7181        assert!(
7182            may_start_oauth_with(&state, &headers, "member.example", resolver_never).await,
7183            "a seated session cookie must pass without resolution"
7184        );
7185    }
7186
7187    /// The invite-cookie fast path admits WITHOUT calling the resolver.
7188    #[tokio::test]
7189    async fn may_start_oauth_invite_cookie_shortcircuits_resolution() {
7190        let state = test_state(&[]).await;
7191        let cookie = sign_invite("FEATHER-ABCDWXYZ", &state.config.cookie_secret);
7192        let cookie = cookie.split(';').next().unwrap().to_string();
7193        let mut headers = HeaderMap::new();
7194        headers.insert(header::COOKIE, cookie.parse().unwrap());
7195        assert!(
7196            may_start_oauth_with(&state, &headers, "someone.example", resolver_never).await,
7197            "a valid invite cookie must pass without resolution"
7198        );
7199    }
7200
7201    #[tokio::test]
7202    async fn admin_mint_requires_admin_seed_did() {
7203        let state = test_state(&["did:plc:admin"]).await;
7204        // A non-admin (but beta'd) session is forbidden.
7205        store::grant_access(&state.db, "did:plc:rando", None, "test", None)
7206            .await
7207            .unwrap();
7208        let rando_cookie = session_cookie(&state, "did:plc:rando", None);
7209        // An admin session is allowed.
7210        let admin_cookie = session_cookie(&state, "did:plc:admin", None);
7211        let app = router(state);
7212
7213        let forbidden = app
7214            .clone()
7215            .oneshot(
7216                Request::builder()
7217                    .method("POST")
7218                    .uri("/admin/invites?n=2")
7219                    .header(header::COOKIE, rando_cookie)
7220                    .body(Body::empty())
7221                    .unwrap(),
7222            )
7223            .await
7224            .unwrap();
7225        assert_eq!(forbidden.status(), StatusCode::FORBIDDEN);
7226
7227        let ok = app
7228            .oneshot(
7229                Request::builder()
7230                    .method("POST")
7231                    .uri("/admin/invites?n=2")
7232                    .header(header::COOKIE, admin_cookie)
7233                    .body(Body::empty())
7234                    .unwrap(),
7235            )
7236            .await
7237            .unwrap();
7238        assert_eq!(ok.status(), StatusCode::OK);
7239        let bytes = axum::body::to_bytes(ok.into_body(), 64 * 1024)
7240            .await
7241            .unwrap();
7242        let body = String::from_utf8(bytes.to_vec()).unwrap();
7243        let minted: Vec<&str> = body.lines().filter(|l| !l.is_empty()).collect();
7244        assert_eq!(minted.len(), 2);
7245        assert!(minted.iter().all(|c| c.starts_with("FEATHER-")));
7246    }
7247
7248    #[tokio::test]
7249    async fn admin_mint_unauthenticated_is_401() {
7250        let state = test_state(&["did:plc:admin"]).await;
7251        let app = router(state);
7252        let resp = app
7253            .oneshot(
7254                Request::builder()
7255                    .method("POST")
7256                    .uri("/admin/invites")
7257                    .body(Body::empty())
7258                    .unwrap(),
7259            )
7260            .await
7261            .unwrap();
7262        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
7263    }
7264
7265    /// A state whose `/about` renders the adoption line, seeded with one
7266    /// observation.
7267    async fn adoption_state(repos: i64, truncated: bool) -> AppState {
7268        let db = store::init_url("sqlite::memory:").await.unwrap();
7269        store::record_network_stat(
7270            &db,
7271            &store::NetworkStat {
7272                key: store::ADOPTION_STAT_KEY.to_string(),
7273                source: "https://relay1.us-west.bsky.network".to_string(),
7274                value: repos,
7275                truncated,
7276                observed_at: "2026-08-13T04:05:06Z".to_string(),
7277            },
7278        )
7279        .await
7280        .unwrap();
7281        let config = Config {
7282            cookie_secret: "test-cookie-secret-000".to_string(),
7283            show_adoption: true,
7284            ..Config::default()
7285        };
7286        AppState::new(config, db).unwrap()
7287    }
7288
7289    async fn about_body(state: AppState) -> String {
7290        let resp = router(state)
7291            .oneshot(
7292                Request::builder()
7293                    .uri("/about")
7294                    .body(Body::empty())
7295                    .unwrap(),
7296            )
7297            .await
7298            .unwrap();
7299        assert_eq!(resp.status(), StatusCode::OK);
7300        let bytes = axum::body::to_bytes(resp.into_body(), 256 * 1024)
7301            .await
7302            .unwrap();
7303        String::from_utf8(bytes.to_vec()).unwrap()
7304    }
7305
7306    /// Default config ⇒ the flag is off ⇒ no line, and no query is issued.
7307    #[tokio::test]
7308    async fn about_omits_adoption_line_by_default() {
7309        let state = test_state(&[]).await;
7310        assert!(!state.config.show_adoption);
7311        let body = about_body(state).await;
7312        assert!(
7313            !body.contains("atproto network"),
7314            "the adoption line must not render by default"
7315        );
7316    }
7317
7318    #[tokio::test]
7319    async fn about_renders_adoption_line_when_enabled() {
7320        // **A distinctive count, and asserted IN ITS SENTENCE.**
7321        //
7322        // This used to seed 4 and assert `body.contains("4")`, which the
7323        // colophon's `width="44"` satisfies whatever the count is — so
7324        // hardcoding the rendered number passed. Both halves are needed: a
7325        // digit that does not occur incidentally, and the assertion tied to the
7326        // phrase it belongs to.
7327        let body = about_body(adoption_state(7_318, false).await).await;
7328        // The count and its phrase are on separate template lines, so compare
7329        // against a whitespace-collapsed copy rather than the raw HTML.
7330        let flat = body.split_whitespace().collect::<Vec<_>>().join(" ");
7331        assert!(
7332            flat.contains("7318 accounts on the atproto network hold"),
7333            "the count did not render in its own sentence: {flat}",
7334        );
7335        assert!(
7336            body.contains("accounts on the atproto network hold"),
7337            "{body}"
7338        );
7339        assert!(
7340            body.contains("2026-08-13"),
7341            "the observation date must render"
7342        );
7343        assert!(
7344            body.contains("lower bound"),
7345            "the non-archival caveat must ride along with the number"
7346        );
7347        assert!(
7348            !body.contains("At least"),
7349            "an untruncated count is exact-ish"
7350        );
7351    }
7352
7353    /// A count of one must read as "1 account … holds", not "1 accounts … hold".
7354    #[tokio::test]
7355    async fn about_adoption_line_is_singular_at_one() {
7356        let body = about_body(adoption_state(1, false).await).await;
7357        assert!(
7358            body.contains("account on the atproto network holds"),
7359            "{body}"
7360        );
7361    }
7362
7363    /// A truncated observation is a floor, and must say so.
7364    #[tokio::test]
7365    async fn about_adoption_line_says_at_least_when_truncated() {
7366        let body = about_body(adoption_state(25_000, true).await).await;
7367        assert!(body.contains("At least"), "{body}");
7368    }
7369
7370    /// Flag on but no observation (or a zero) ⇒ 200, no line, no error.
7371    #[tokio::test]
7372    async fn about_omits_line_when_enabled_with_no_observation() {
7373        let db = store::init_url("sqlite::memory:").await.unwrap();
7374        let config = Config {
7375            cookie_secret: "test-cookie-secret-000".to_string(),
7376            show_adoption: true,
7377            ..Config::default()
7378        };
7379        let body = about_body(AppState::new(config, db).unwrap()).await;
7380        assert!(!body.contains("atproto network"));
7381    }
7382
7383    // ---- standard.site on the public pages and the subscribe form ----------
7384    //
7385    // `FEATHERREADER_STANDARD_SITE` gates what may be STORED (`add_subscription`
7386    // refuses every `at://` paste with it off), so a page that tells the reader
7387    // to paste a publication URI is advertising a form that will be refused
7388    // unless the flag is on. These pin both halves: with the flag on the pages
7389    // say how; with it off they do not.
7390
7391    /// A state with the standard.site flag chosen, and `did` holding a seat so
7392    /// `/manage` renders for it.
7393    async fn standard_site_state(standard_site: bool, did: &str) -> AppState {
7394        let db = store::init_url("sqlite::memory:").await.unwrap();
7395        store::ensure_seed(&db, &[did.to_string()]).await.unwrap();
7396        let config = Config {
7397            allowed_dids: vec![did.to_string()],
7398            cookie_secret: "test-cookie-secret-000".to_string(),
7399            beta_cap: 3,
7400            standard_site,
7401            ..Config::default()
7402        };
7403        AppState::new(config, db).unwrap()
7404    }
7405
7406    /// `GET path` as `did`, asserted 200, body as a string.
7407    async fn signed_in_body(state: AppState, path: &str, did: &str) -> String {
7408        let cookie = session_cookie(&state, did, Some("reader.example"));
7409        let resp = router(state)
7410            .oneshot(
7411                Request::builder()
7412                    .uri(path)
7413                    .header(header::COOKIE, cookie)
7414                    .body(Body::empty())
7415                    .unwrap(),
7416            )
7417            .await
7418            .unwrap();
7419        assert_eq!(resp.status(), StatusCode::OK, "{path}");
7420        let bytes = axum::body::to_bytes(resp.into_body(), 512 * 1024)
7421            .await
7422            .unwrap();
7423        String::from_utf8(bytes.to_vec()).unwrap()
7424    }
7425
7426    /// `GET path` signed out, asserted 200, body as a string.
7427    async fn public_body(state: AppState, path: &str) -> String {
7428        let resp = router(state)
7429            .oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
7430            .await
7431            .unwrap();
7432        assert_eq!(resp.status(), StatusCode::OK, "{path}");
7433        let bytes = axum::body::to_bytes(resp.into_body(), 512 * 1024)
7434            .await
7435            .unwrap();
7436        String::from_utf8(bytes.to_vec()).unwrap()
7437    }
7438
7439    /// The `<input … id="feed-url" …>` tag of the subscribe form, whole.
7440    fn feed_url_input(body: &str) -> &str {
7441        let start = body
7442            .find("id=\"feed-url\"")
7443            .and_then(|i| body[..i].rfind("<input"))
7444            .expect("the subscribe form's URL input renders");
7445        let end = body[start..].find('>').expect("the input tag closes") + start + 1;
7446        &body[start..end]
7447    }
7448
7449    /// Flag on: the subscribe form says a publication URI is accepted, and shows
7450    /// both spellings the handler takes (DID and handle).
7451    #[tokio::test]
7452    async fn manage_hints_at_publications_when_the_flag_is_on() {
7453        let did = "did:plc:reader";
7454        let body = signed_in_body(standard_site_state(true, did).await, "/manage", did).await;
7455        assert!(
7456            body.contains("at://did:plc:…/site.standard.publication/…"),
7457            "the DID form must be shown: {body}"
7458        );
7459        assert!(
7460            body.contains("at://alice.example.com/site.standard.publication/…"),
7461            "the handle form must be shown: {body}"
7462        );
7463    }
7464
7465    /// Flag on: the URL input must not be `type="url"`. A browser validates
7466    /// that type with the WHATWG URL parser, which REJECTS the DID form —
7467    /// `at://did:plc:…/…` fails as an invalid port, the same failure
7468    /// `url::Url::parse` has (see `feed::is_storable_feed_url`) — so the form
7469    /// would refuse to submit the very string the hint asks for.
7470    #[tokio::test]
7471    async fn manage_url_input_accepts_a_did_uri_when_the_flag_is_on() {
7472        let did = "did:plc:reader";
7473        let body = signed_in_body(standard_site_state(true, did).await, "/manage", did).await;
7474        let input = feed_url_input(&body);
7475        assert!(
7476            input.contains("type=\"text\""),
7477            "the input must be type=text so a DID-form at:// URI can be submitted: {input}"
7478        );
7479        assert!(
7480            input.contains("inputmode=\"url\""),
7481            "the URL keyboard is still wanted: {input}"
7482        );
7483    }
7484
7485    /// Flag on, `type="text"` drops the browser's scheme check, so a pasted
7486    /// `example.com/blog` would reach the handler and come back as "Couldn't
7487    /// find a feed" — wrong, the site likely has one. A `pattern` keeps the
7488    /// browser asking for a scheme while still admitting `at://` (both cases:
7489    /// the handler canonicalises the scheme).
7490    #[tokio::test]
7491    async fn manage_url_input_still_requires_a_scheme_when_the_flag_is_on() {
7492        let did = "did:plc:reader";
7493        let body = signed_in_body(standard_site_state(true, did).await, "/manage", did).await;
7494        let input = feed_url_input(&body);
7495        assert!(
7496            input.contains(&format!("pattern=\"{FEED_URL_PATTERN}\"")),
7497            "the text input must keep a scheme check: {input}"
7498        );
7499    }
7500
7501    /// Flag off: every `at://` paste is refused, so the form must not say
7502    /// publications are accepted — and the input keeps browser URL validation.
7503    #[tokio::test]
7504    async fn manage_does_not_advertise_publications_when_the_flag_is_off() {
7505        let did = "did:plc:reader";
7506        let state = standard_site_state(false, did).await;
7507        assert!(!state.config.standard_site);
7508        let page = signed_in_body(state, "/manage", did).await;
7509        // The `<head>` carries the site's link card, whose one-line description
7510        // names standard.site whatever the flag says — as the landing page does
7511        // with the flag off (a stored publication is polled regardless). What
7512        // must not advertise is the page: everything after `</head>`.
7513        let body = &page[page.find("</head>").expect("a <head>")..];
7514        assert!(
7515            !body.contains("site.standard.publication"),
7516            "a refused form must not be advertised: {body}"
7517        );
7518        // The shared footer links the `/standard-site` feature page on every
7519        // page, flag on or off — that page itself says the instance isn't
7520        // accepting new publication subscriptions — so the check is on the
7521        // page above the footer, where the form and its hints are.
7522        let above_footer = body
7523            .split("<footer")
7524            .next()
7525            .expect("split yields at least one piece");
7526        assert!(
7527            above_footer.contains("id=\"feed-url\""),
7528            "the form must be above the footer: {body}"
7529        );
7530        assert!(
7531            !above_footer.contains("standard.site"),
7532            "a refused form must not be advertised: {body}"
7533        );
7534        assert!(
7535            feed_url_input(body).contains("type=\"url\""),
7536            "with the flag off the input is unchanged"
7537        );
7538    }
7539
7540    /// Flag on: the landing page says publications sit beside feeds AND how to
7541    /// subscribe to one.
7542    #[tokio::test]
7543    async fn landing_describes_publications_and_how_to_subscribe_when_on() {
7544        let body = public_body(standard_site_state(true, "did:plc:x").await, "/").await;
7545        assert!(body.contains("standard.site"), "{body}");
7546        assert!(
7547            body.contains("at://did:plc:…/site.standard.publication/…"),
7548            "the landing page must show the DID form: {body}"
7549        );
7550        assert!(
7551            body.contains("at://alice.example.com/site.standard.publication/…"),
7552            "the landing page must show the handle form: {body}"
7553        );
7554    }
7555
7556    /// Flag off: the landing page still says what a publication is (a stored
7557    /// one is polled whatever the flag says), but shows no paste instructions
7558    /// and says new ones are not accepted here.
7559    #[tokio::test]
7560    async fn landing_does_not_tell_visitors_to_paste_a_publication_when_off() {
7561        let body = public_body(standard_site_state(false, "did:plc:x").await, "/").await;
7562        assert!(body.contains("standard.site"), "{body}");
7563        assert!(
7564            !body.contains("at://did:plc:…/site.standard.publication/…"),
7565            "no paste instructions with the flag off: {body}"
7566        );
7567        assert!(
7568            !body.contains("at://alice.example.com/site.standard.publication/…"),
7569            "no paste instructions with the flag off: {body}"
7570        );
7571        assert!(
7572            body.contains("isn't accepting new publication subscriptions"),
7573            "the page must say the form is closed here: {body}"
7574        );
7575    }
7576
7577    /// Flag on: /about has a publications section with both spellings.
7578    #[tokio::test]
7579    async fn about_describes_publications_and_how_to_subscribe_when_on() {
7580        let body = public_body(standard_site_state(true, "did:plc:x").await, "/about").await;
7581        assert!(body.contains("site.standard.publication"), "{body}");
7582        assert!(body.contains("site.standard.document"), "{body}");
7583        assert!(
7584            body.contains("at://did:plc:…/site.standard.publication/…"),
7585            "{body}"
7586        );
7587        assert!(
7588            body.contains("at://alice.example.com/site.standard.publication/…"),
7589            "{body}"
7590        );
7591    }
7592
7593    /// Flag off: /about keeps the description, drops the paste instructions.
7594    #[tokio::test]
7595    async fn about_does_not_tell_visitors_to_paste_a_publication_when_off() {
7596        let body = public_body(standard_site_state(false, "did:plc:x").await, "/about").await;
7597        assert!(body.contains("site.standard.publication"), "{body}");
7598        assert!(
7599            !body.contains("at://did:plc:…/site.standard.publication/…"),
7600            "no paste instructions with the flag off: {body}"
7601        );
7602        assert!(
7603            !body.contains("at://alice.example.com/site.standard.publication/…"),
7604            "no paste instructions with the flag off: {body}"
7605        );
7606        assert!(
7607            body.contains("isn't accepting new publication subscriptions"),
7608            "{body}"
7609        );
7610    }
7611
7612    // ---- the standard.site feature page (`/standard-site`) -----------------
7613    //
7614    // A public page, like `/about`: what a publication is, what is shown from
7615    // it, how to subscribe (flag-conditional, as on the other public pages),
7616    // and the honest limits. It also carries the "latest releases" call-out.
7617
7618    /// Signed out, with the default config, the page renders.
7619    #[tokio::test]
7620    async fn standard_site_page_renders_signed_out() {
7621        let body = public_body(test_state(&[]).await, "/standard-site").await;
7622        assert!(body.contains("site.standard.publication"), "{body}");
7623        assert!(body.contains("site.standard.document"), "{body}");
7624        assert!(
7625            body.contains("<title>standard.site — FeatherReader</title>"),
7626            "{body}"
7627        );
7628    }
7629
7630    /// Flag on: the page says how to subscribe, in both spellings, and that a
7631    /// handle is resolved to its DID.
7632    #[tokio::test]
7633    async fn standard_site_page_tells_how_to_subscribe_when_on() {
7634        let body = public_body(
7635            standard_site_state(true, "did:plc:x").await,
7636            "/standard-site",
7637        )
7638        .await;
7639        assert!(
7640            body.contains("at://did:plc:…/site.standard.publication/…"),
7641            "the DID form must be shown: {body}"
7642        );
7643        assert!(
7644            body.contains("at://alice.example.com/site.standard.publication/…"),
7645            "the handle form must be shown: {body}"
7646        );
7647        assert!(
7648            body.contains("resolved to its DID"),
7649            "the handle resolution must be stated: {body}"
7650        );
7651        assert!(
7652            !body.contains("isn't accepting new publication subscriptions"),
7653            "{body}"
7654        );
7655    }
7656
7657    /// Flag off: `add_subscription` refuses every `at://` paste, so the page
7658    /// must not tell visitors to paste one — it says new publication
7659    /// subscriptions are not accepted here, and that stored ones are still read.
7660    #[tokio::test]
7661    async fn standard_site_page_does_not_tell_visitors_to_paste_when_off() {
7662        let state = standard_site_state(false, "did:plc:x").await;
7663        assert!(!state.config.standard_site);
7664        let body = public_body(state, "/standard-site").await;
7665        assert!(body.contains("site.standard.publication"), "{body}");
7666        assert!(
7667            !body.contains("at://did:plc:…/site.standard.publication/…"),
7668            "no paste instructions with the flag off: {body}"
7669        );
7670        assert!(
7671            !body.contains("at://alice.example.com/site.standard.publication/…"),
7672            "no paste instructions with the flag off: {body}"
7673        );
7674        assert!(
7675            body.contains("isn't accepting new publication subscriptions"),
7676            "the page must say the form is closed here: {body}"
7677        );
7678        assert!(
7679            body.contains("already follows are still read"),
7680            "stored publications are polled whatever the flag says: {body}"
7681        );
7682    }
7683
7684    /// The releases call-out links each release's GitHub page and the
7685    /// changelog, on the feature page and on the landing page.
7686    #[tokio::test]
7687    async fn releases_callout_links_the_release_pages() {
7688        for path in ["/standard-site", "/"] {
7689            let body = public_body(test_state(&[]).await, path).await;
7690            for tag in ["v0.4.1", "v0.4.0"] {
7691                let href = format!(
7692                    "href=\"https://github.com/justin-stanley/feather-reader/releases/tag/{tag}\""
7693                );
7694                assert!(body.contains(&href), "{path} must link {tag}: {body}");
7695            }
7696            assert!(
7697                body.contains(
7698                    "https://github.com/justin-stanley/feather-reader/blob/main/CHANGELOG.md"
7699                ),
7700                "{path} must link the changelog: {body}"
7701            );
7702        }
7703    }
7704
7705    /// The feature page is reachable from the landing page, from `/about`, and
7706    /// from the shared footer (`/privacy` renders nothing but prose and that
7707    /// footer, so it stands in for every page that includes it).
7708    #[tokio::test]
7709    async fn landing_about_and_footer_link_the_standard_site_page() {
7710        for path in ["/", "/about", "/privacy"] {
7711            let body = public_body(test_state(&[]).await, path).await;
7712            assert!(
7713                body.contains("href=\"/standard-site\""),
7714                "{path} must link the feature page: {body}"
7715            );
7716        }
7717    }
7718
7719    /// `RELEASES` is the one place a release is described, so its shape is
7720    /// pinned: newest first, dates as the CHANGELOG headings spell them, and
7721    /// both derived links pointing where the template promises.
7722    #[test]
7723    fn releases_are_newest_first_and_link_the_tag_and_changelog() {
7724        assert!(!RELEASES.is_empty());
7725        let parse = |v: &str| -> Vec<u32> {
7726            v.split('.')
7727                .map(|p| p.parse::<u32>().expect("a numeric version part"))
7728                .collect()
7729        };
7730        for pair in RELEASES.windows(2) {
7731            assert!(
7732                parse(pair[0].version) > parse(pair[1].version),
7733                "{} must come before {}",
7734                pair[0].version,
7735                pair[1].version
7736            );
7737        }
7738        for r in RELEASES {
7739            assert_eq!(parse(r.version).len(), 3, "{}", r.version);
7740            assert!(
7741                chrono::NaiveDate::parse_from_str(r.date, "%Y-%m-%d").is_ok(),
7742                "{} is not YYYY-MM-DD",
7743                r.date
7744            );
7745            assert!(!r.summary.trim().is_empty());
7746            assert!(!r.summary.contains('<'), "the summary is plain text");
7747            assert_eq!(
7748                r.url(),
7749                format!(
7750                    "https://github.com/justin-stanley/feather-reader/releases/tag/v{}",
7751                    r.version
7752                )
7753            );
7754        }
7755        // The newest entry is this build's own version, so a release cannot
7756        // ship without adding itself to the call-out.
7757        let latest = &RELEASES[0];
7758        assert_eq!(latest.version, env!("CARGO_PKG_VERSION"));
7759        assert_eq!(
7760            latest.changelog_url(),
7761            "https://github.com/justin-stanley/feather-reader/blob/main/CHANGELOG.md#043--2026-10-05"
7762        );
7763    }
7764
7765    /// Public and static like `/about`, so it is cacheable on the same terms.
7766    #[tokio::test]
7767    async fn standard_site_page_is_publicly_cacheable() {
7768        let resp = router(test_state(&[]).await)
7769            .oneshot(
7770                Request::builder()
7771                    .uri("/standard-site")
7772                    .body(Body::empty())
7773                    .unwrap(),
7774            )
7775            .await
7776            .unwrap();
7777        assert_eq!(resp.status(), StatusCode::OK);
7778        assert_eq!(
7779            resp.headers().get(header::CACHE_CONTROL).unwrap(),
7780            "public, max-age=300"
7781        );
7782    }
7783
7784    #[tokio::test]
7785    async fn cache_control_public_on_about_no_store_on_authed() {
7786        let state = test_state(&["did:plc:admin"]).await;
7787        let admin_cookie = session_cookie(&state, "did:plc:admin", None);
7788        let app = router(state);
7789
7790        // /about → public, cacheable.
7791        let about = app
7792            .clone()
7793            .oneshot(
7794                Request::builder()
7795                    .uri("/about")
7796                    .body(Body::empty())
7797                    .unwrap(),
7798            )
7799            .await
7800            .unwrap();
7801        assert_eq!(
7802            about.headers().get(header::CACHE_CONTROL).unwrap(),
7803            "public, max-age=300"
7804        );
7805        // The security headers are still intact.
7806        // The VALUE, spelled out here rather than compared to the constant —
7807        // `== CONTENT_SECURITY_POLICY` passes with the constant gutted. This
7808        // used to assert only that the header existed, which a policy of
7809        // `default-src *` satisfies.
7810        assert_eq!(
7811            about.headers()["content-security-policy"],
7812            EXPECTED_CSP,
7813            "the CSP is not the policy the router promises"
7814        );
7815        assert_eq!(about.headers().get("x-frame-options").unwrap(), "DENY");
7816
7817        // /privacy and /terms are static public pages → public, cacheable.
7818        for path in ["/privacy", "/terms"] {
7819            let resp = app
7820                .clone()
7821                .oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
7822                .await
7823                .unwrap();
7824            assert_eq!(resp.status(), StatusCode::OK);
7825            assert_eq!(
7826                resp.headers().get(header::CACHE_CONTROL).unwrap(),
7827                "public, max-age=300",
7828                "{path} should be publicly cacheable"
7829            );
7830            // Security headers apply to these pages too.
7831            assert_eq!(resp.headers()["content-security-policy"], EXPECTED_CSP);
7832            assert_eq!(resp.headers().get("x-frame-options").unwrap(), "DENY");
7833        }
7834
7835        // The bare /login landing → public, cacheable.
7836        let login = app
7837            .clone()
7838            .oneshot(
7839                Request::builder()
7840                    .uri("/login")
7841                    .body(Body::empty())
7842                    .unwrap(),
7843            )
7844            .await
7845            .unwrap();
7846        assert_eq!(
7847            login.headers().get(header::CACHE_CONTROL).unwrap(),
7848            "public, max-age=300"
7849        );
7850
7851        // An authenticated page → no-store.
7852        let home = app
7853            .oneshot(
7854                Request::builder()
7855                    .uri("/")
7856                    .header(header::COOKIE, admin_cookie)
7857                    .body(Body::empty())
7858                    .unwrap(),
7859            )
7860            .await
7861            .unwrap();
7862        assert_eq!(
7863            home.headers().get(header::CACHE_CONTROL).unwrap(),
7864            "no-store"
7865        );
7866    }
7867
7868    // -- link cards (Open Graph) -----------------------------------------------
7869    //
7870    // Bluesky's card service fetches the HTML server-side, runs no JS, and
7871    // resolves nothing relative. Measured before these tags existed:
7872    // `cardyb.bsky.app/v1/extract?url=https://feather-reader.com/` returned
7873    // `{"title":"FeatherReader — read, quietly","description":"","image":""}`.
7874
7875    /// Everything up to `</head>` — the only part a card fetcher reads.
7876    fn head(body: &str) -> &str {
7877        let end = body.find("</head>").expect("a <head>");
7878        &body[..end]
7879    }
7880
7881    /// The `content` of the first `<meta …>` tag carrying `attr` (e.g.
7882    /// `property="og:title"`), or `None` when no tag carries it.
7883    fn meta(head: &str, attr: &str) -> Option<String> {
7884        let tag_start = head.find(attr)?;
7885        let rest = &head[tag_start..];
7886        let tag_end = rest.find('>')?;
7887        let tag = &rest[..tag_end];
7888        let content = tag.find("content=\"")? + "content=\"".len();
7889        let close = tag[content..].find('"')?;
7890        Some(tag[content..content + close].to_string())
7891    }
7892
7893    /// A state whose public origin is production's. The card URLs must be
7894    /// absolute on THAT origin: a relative `/static/…` is what the card
7895    /// fetcher cannot use.
7896    async fn production_origin_state() -> AppState {
7897        let db = store::init_url("sqlite::memory:").await.unwrap();
7898        store::ensure_seed(&db, &["did:plc:admin".to_string()])
7899            .await
7900            .unwrap();
7901        let config = Config {
7902            allowed_dids: vec!["did:plc:admin".to_string()],
7903            cookie_secret: "test-cookie-secret-000".to_string(),
7904            beta_cap: 3,
7905            public_url: "https://feather-reader.com".to_string(),
7906            ..Config::default()
7907        };
7908        AppState::new(config, db).unwrap()
7909    }
7910
7911    /// The landing page and /about each carry a complete card with absolute
7912    /// https URLs, and the two describe different things.
7913    #[tokio::test]
7914    async fn landing_and_about_render_open_graph_cards_with_absolute_urls() {
7915        let landing = public_body(production_origin_state().await, "/").await;
7916        let about = public_body(production_origin_state().await, "/about").await;
7917        let (lh, ah) = (head(&landing), head(&about));
7918
7919        assert_eq!(
7920            meta(lh, "property=\"og:title\"").as_deref(),
7921            Some("FeatherReader — read, quietly"),
7922            "{lh}"
7923        );
7924        assert_eq!(
7925            meta(ah, "property=\"og:title\"").as_deref(),
7926            Some("About — FeatherReader"),
7927            "{ah}"
7928        );
7929        for (h, path) in [(lh, "/"), (ah, "/about")] {
7930            let url = format!("https://feather-reader.com{path}");
7931            assert_eq!(
7932                meta(h, "property=\"og:url\"").as_deref(),
7933                Some(url.as_str())
7934            );
7935            assert!(
7936                h.contains(&format!("<link rel=\"canonical\" href=\"{url}\"")),
7937                "{path} must carry a canonical link: {h}"
7938            );
7939            let image = meta(h, "property=\"og:image\"").unwrap_or_default();
7940            assert!(
7941                image.starts_with("https://feather-reader.com/static/"),
7942                "{path}: og:image must be absolute on the public origin, got {image:?}"
7943            );
7944            assert_eq!(
7945                meta(h, "name=\"twitter:card\"").as_deref(),
7946                Some("summary_large_image")
7947            );
7948            assert_eq!(meta(h, "property=\"og:type\"").as_deref(), Some("website"));
7949            assert_eq!(
7950                meta(h, "property=\"og:site_name\"").as_deref(),
7951                Some("FeatherReader")
7952            );
7953            let description = meta(h, "property=\"og:description\"").unwrap_or_default();
7954            assert!(!description.is_empty(), "{path}: og:description is empty");
7955            assert_eq!(
7956                meta(h, "name=\"description\"").as_deref(),
7957                Some(description.as_str()),
7958                "{path}: the meta description and og:description must agree"
7959            );
7960        }
7961        assert_ne!(
7962            meta(lh, "property=\"og:description\""),
7963            meta(ah, "property=\"og:description\""),
7964            "the landing page and /about must not share a description"
7965        );
7966    }
7967
7968    /// The origin comes from `FEATHERREADER_PUBLIC_URL`, not a constant.
7969    #[tokio::test]
7970    async fn card_urls_follow_the_configured_public_url() {
7971        let db = store::init_url("sqlite::memory:").await.unwrap();
7972        store::ensure_seed(&db, &[]).await.unwrap();
7973        let config = Config {
7974            cookie_secret: "test-cookie-secret-000".to_string(),
7975            public_url: "https://reader.example.org".to_string(),
7976            ..Config::default()
7977        };
7978        let body = public_body(AppState::new(config, db).unwrap(), "/privacy").await;
7979        let h = head(&body);
7980        assert_eq!(
7981            meta(h, "property=\"og:url\"").as_deref(),
7982            Some("https://reader.example.org/privacy")
7983        );
7984        assert_eq!(
7985            meta(h, "property=\"og:image\"").as_deref(),
7986            Some("https://reader.example.org/static/social-card.png")
7987        );
7988    }
7989
7990    /// Every signed-out page describes itself: no two share a description,
7991    /// and each `og:url` is its own path.
7992    #[tokio::test]
7993    async fn public_pages_each_carry_their_own_description() {
7994        let paths = [
7995            "/",
7996            "/about",
7997            "/privacy",
7998            "/terms",
7999            "/stats",
8000            "/standard-site",
8001            "/login",
8002            "/beta/redeem",
8003        ];
8004        let mut seen = std::collections::HashSet::new();
8005        for path in paths {
8006            let body = public_body(production_origin_state().await, path).await;
8007            let h = head(&body);
8008            let description = meta(h, "name=\"description\"").unwrap_or_default();
8009            assert!(!description.is_empty(), "{path} has no description: {h}");
8010            assert!(
8011                seen.insert(description.clone()),
8012                "{path} repeats another page's description: {description:?}"
8013            );
8014            assert_eq!(
8015                meta(h, "property=\"og:url\"").as_deref(),
8016                Some(format!("https://feather-reader.com{path}").as_str()),
8017                "{path}"
8018            );
8019            assert!(
8020                !h.contains("name=\"robots\""),
8021                "{path} is public and must not be noindex: {h}"
8022            );
8023        }
8024    }
8025
8026    /// The share image is served from `/static` as a PNG of the dimensions the
8027    /// tags promise, well under the 1 MB card fetchers tolerate, and cacheable.
8028    #[tokio::test]
8029    async fn share_image_is_served_as_a_png_of_the_advertised_size() {
8030        let landing = public_body(production_origin_state().await, "/").await;
8031        let h = head(&landing);
8032        let image = meta(h, "property=\"og:image\"").unwrap();
8033        let path = image.strip_prefix("https://feather-reader.com").unwrap();
8034        let width: u32 = meta(h, "property=\"og:image:width\"")
8035            .unwrap()
8036            .parse()
8037            .unwrap();
8038        let height: u32 = meta(h, "property=\"og:image:height\"")
8039            .unwrap()
8040            .parse()
8041            .unwrap();
8042        assert_eq!((width, height), (1200, 630), "Bluesky renders ~1.91:1");
8043        assert_eq!(
8044            meta(h, "property=\"og:image:type\"").as_deref(),
8045            Some("image/png")
8046        );
8047        assert!(
8048            !meta(h, "property=\"og:image:alt\"")
8049                .unwrap_or_default()
8050                .is_empty(),
8051            "the image needs alt text"
8052        );
8053
8054        let resp = router(production_origin_state().await)
8055            .oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
8056            .await
8057            .unwrap();
8058        assert_eq!(resp.status(), StatusCode::OK, "{path}");
8059        assert_eq!(resp.headers()[header::CONTENT_TYPE], "image/png");
8060        assert_eq!(resp.headers()[header::CACHE_CONTROL], "public, max-age=300");
8061        let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
8062            .await
8063            .expect("the image is under 1 MB");
8064        assert_eq!(&bytes[..8], b"\x89PNG\r\n\x1a\n", "not a PNG");
8065        // IHDR: width and height, big-endian, at offsets 16 and 20.
8066        let be = |at: usize| u32::from_be_bytes(bytes[at..at + 4].try_into().unwrap());
8067        assert_eq!(
8068            (be(16), be(20)),
8069            (width, height),
8070            "the PNG's own dimensions must match the tags"
8071        );
8072    }
8073
8074    /// A page that renders a session's private view carries the site's generic
8075    /// card — nothing from the view reaches `<head>` — and is `noindex`.
8076    #[tokio::test]
8077    async fn private_pages_keep_user_data_out_of_the_card() {
8078        for path in ["/", "/manage"] {
8079            let state = production_origin_state().await;
8080            let body = signed_in_body(state, path, "did:plc:admin").await;
8081            let h = head(&body);
8082            assert!(
8083                h.contains("<meta name=\"robots\" content=\"noindex\""),
8084                "{path}: a private view must be noindex: {h}"
8085            );
8086            assert_eq!(
8087                meta(h, "property=\"og:title\"").as_deref(),
8088                Some("FeatherReader — read, quietly"),
8089                "{path}: the card of a private view is the site's generic one"
8090            );
8091            assert_eq!(
8092                meta(h, "property=\"og:url\"").as_deref(),
8093                Some("https://feather-reader.com/"),
8094                "{path}: og:url of a private view is the front door, not the private path"
8095            );
8096            for private in ["reader.example", "did:plc:admin"] {
8097                assert!(
8098                    !h.contains(private),
8099                    "{path}: {private:?} must not reach <head>: {h}"
8100                );
8101            }
8102        }
8103    }
8104
8105    #[tokio::test]
8106    async fn beta_redeem_page_renders() {
8107        let state = test_state(&[]).await;
8108        let app = router(state);
8109        let resp = app
8110            .oneshot(
8111                Request::builder()
8112                    .uri("/beta/redeem")
8113                    .body(Body::empty())
8114                    .unwrap(),
8115            )
8116            .await
8117            .unwrap();
8118        assert_eq!(resp.status(), StatusCode::OK);
8119        let bytes = axum::body::to_bytes(resp.into_body(), 256 * 1024)
8120            .await
8121            .unwrap();
8122        let html = String::from_utf8(bytes.to_vec()).unwrap();
8123        assert!(html.contains("Invite code"));
8124        assert!(html.contains("/beta/redeem"));
8125    }
8126
8127    #[tokio::test]
8128    async fn rate_limit_returns_429_after_burst() {
8129        // Configure a trusted proxy header so the limiter keys on the forwarded
8130        // IP (the oneshot harness sets no ConnectInfo socket peer).
8131        let db = store::init_url("sqlite::memory:").await.unwrap();
8132        store::ensure_seed(&db, &[]).await.unwrap();
8133        let config = Config {
8134            cookie_secret: "test-cookie-secret-000".to_string(),
8135            beta_cap: 3,
8136            trusted_ip_header: Some("cf-connecting-ip".to_string()),
8137            ..Config::default()
8138        };
8139        let state = AppState::new(config, db).unwrap();
8140        let app = router(state);
8141        // Hammer POST /beta/redeem past the burst from a single (trusted) IP. The
8142        // handler itself returns 200 (re-render) on a bad code; the limiter is
8143        // what eventually yields 429.
8144        let mut saw_429 = false;
8145        for _ in 0..(RATE_BURST as usize + 5) {
8146            let resp = app
8147                .clone()
8148                .oneshot(
8149                    Request::builder()
8150                        .method("POST")
8151                        .uri("/beta/redeem")
8152                        .header("content-type", "application/x-www-form-urlencoded")
8153                        .header("cf-connecting-ip", "203.0.113.200")
8154                        .body(Body::from("code=FEATHER-NOPENOPE"))
8155                        .unwrap(),
8156                )
8157                .await
8158                .unwrap();
8159            if resp.status() == StatusCode::TOO_MANY_REQUESTS {
8160                saw_429 = true;
8161                break;
8162            }
8163        }
8164        assert!(saw_429, "expected a 429 after exhausting the burst");
8165    }
8166
8167    /// **A forged `X-Forwarded-For` does not key the limiter** — the property
8168    /// the middleware's comment cites this test as proof of.
8169    ///
8170    /// The previous version rotated the forged header and asserted that no
8171    /// request was EVER 429'd. Rotating addresses can never exhaust a per-IP
8172    /// burst, so that assertion held whether the header was trusted or
8173    /// ignored — it passed in the vulnerable configuration too. And with no
8174    /// socket peer the limiter fails open, so nothing could have been keyed on
8175    /// anything. Now one real peer sends `RATE_BURST + 5` requests, each with
8176    /// a DIFFERENT forged header, and the last must be 429: they all landed in
8177    /// the peer's bucket. A limiter keying on the header mints a fresh bucket
8178    /// per request and never trips — which is exactly what the mutation does.
8179    #[tokio::test]
8180    async fn a_forged_forwarded_for_header_does_not_key_the_limiter() {
8181        let state = test_state(&[]).await;
8182        assert!(
8183            state.config.trusted_ip_header.is_none(),
8184            "no proxy header is trusted here"
8185        );
8186        let app = router(state);
8187        let peer = std::net::SocketAddr::from(([203, 0, 113, 7], 40000));
8188        let mut saw_429 = false;
8189        for i in 0..(RATE_BURST as usize + 5) {
8190            let forged = format!("10.9.8.{}", i % 250);
8191            let resp = app
8192                .clone()
8193                .oneshot(
8194                    Request::builder()
8195                        .method("POST")
8196                        .uri("/beta/redeem")
8197                        .header("content-type", "application/x-www-form-urlencoded")
8198                        .header("x-forwarded-for", forged)
8199                        .extension(axum::extract::ConnectInfo(peer))
8200                        .body(Body::from("code=FEATHER-NOPENOPE"))
8201                        .unwrap(),
8202                )
8203                .await
8204                .unwrap();
8205            if resp.status() == StatusCode::TOO_MANY_REQUESTS {
8206                saw_429 = true;
8207                break;
8208            }
8209        }
8210        assert!(
8211            saw_429,
8212            "rotating a forged X-Forwarded-For minted fresh buckets: the limiter is keyed on an attacker-chosen header"
8213        );
8214    }
8215
8216    // -- OPML import body cap (DefaultBodyLimit → 413) -------------------------
8217
8218    /// **A private feed is refused BEFORE it is fetched.** The add path's
8219    /// privacy gate had no test at all — `private_feeds_are_classified_private_
8220    /// across_providers` says "the add + OPML paths both gate on this
8221    /// classifier" and nothing checked either. The gate exists so a
8222    /// token-bearing URL never reaches the network; the assertion that
8223    /// matters is the server's hit count: zero.
8224    #[tokio::test]
8225    async fn subscribing_to_a_private_feed_never_reaches_the_network() {
8226        let did = "did:plc:privateadder";
8227        let state = test_state_with_caps(did, 0, 0).await;
8228        let (base, hits) = crate::net::tests::serve_body_counted(b"<rss/>".to_vec()).await;
8229        let port: u16 = base
8230            .trim_end_matches('/')
8231            .rsplit(':')
8232            .next()
8233            .unwrap()
8234            .parse()
8235            .unwrap();
8236        crate::net::test_host_override(
8237            "private-add.test",
8238            std::net::SocketAddr::from(([127, 0, 0, 1], port)),
8239        );
8240        let cookie = session_cookie(&state, did, None);
8241        let resp = router(state.clone())
8242            .oneshot(
8243                Request::builder()
8244                    .method("POST")
8245                    .uri("/subscriptions")
8246                    .header(header::COOKIE, cookie)
8247                    .header("content-type", "application/x-www-form-urlencoded")
8248                    .body(Body::from(format!(
8249                        "url=http%3A%2F%2Fprivate-add.test%3A{port}%2Ffeed%2Fprivate%2Fdeadbeefcafe1234"
8250                    )))
8251                    .unwrap(),
8252            )
8253            .await
8254            .unwrap();
8255        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8256        let loc = resp
8257            .headers()
8258            .get(header::LOCATION)
8259            .unwrap()
8260            .to_str()
8261            .unwrap();
8262        assert!(loc.contains("Private"), "not refused as private: {loc}");
8263        assert_eq!(
8264            hits.load(std::sync::atomic::Ordering::SeqCst),
8265            0,
8266            "the private feed was FETCHED before being refused"
8267        );
8268        assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
8269    }
8270
8271    /// **OPML import skips a private feed without storing or publishing it.**
8272    /// The import path does not fetch, so "never fetched" is not the signal
8273    /// here; "never stored, never written to the PDS" is. The batch write's
8274    /// bytes are captured and must not carry the URL.
8275    #[tokio::test]
8276    async fn opml_import_skips_a_private_feed_without_storing_or_publishing_it() {
8277        let did = "did:plc:renamer4";
8278        let (sidecar, bodies) = spawn_logging_sidecar().await;
8279        let state = test_state_with_sidecar(&[did], &sidecar).await;
8280        let tokened = "https://www.patreon.com/rss/author?auth=Zm9vYmFyc2VjcmV0dG9rZW4";
8281        let opml = format!(
8282            "<?xml version=\"1.0\"?>\n<opml version=\"2.0\"><head><title>t</title></head><body>\n\
8283             <outline type=\"rss\" text=\"Public\" xmlUrl=\"https://public.example/feed.xml\"/>\n\
8284             <outline type=\"rss\" text=\"Paid\" xmlUrl=\"{tokened}\"/>\n\
8285             </body></opml>"
8286        );
8287        let (ct, body) = opml_multipart(opml.as_bytes());
8288        let cookie = session_cookie(&state, did, None);
8289        let resp = router(state.clone())
8290            .oneshot(
8291                Request::builder()
8292                    .method("POST")
8293                    .uri("/opml")
8294                    .header(header::COOKIE, cookie)
8295                    .header("content-type", ct)
8296                    .body(Body::from(body))
8297                    .unwrap(),
8298            )
8299            .await
8300            .unwrap();
8301        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8302        let loc = resp
8303            .headers()
8304            .get(header::LOCATION)
8305            .unwrap()
8306            .to_str()
8307            .unwrap();
8308        assert!(
8309            loc.contains("skipped%20as%20private"),
8310            "not reported as skipped: {loc}"
8311        );
8312        assert!(store::get_feed_by_url(&state.db, tokened)
8313            .await
8314            .unwrap()
8315            .is_none());
8316        let sent = bodies.lock().unwrap().join("\n");
8317        assert!(
8318            sent.contains("public.example"),
8319            "the public feed was not written: {sent}"
8320        );
8321        assert!(
8322            !sent.contains("Zm9vYmFyc2VjcmV0dG9rZW4"),
8323            "the secret was PUBLISHED to the PDS: {sent}"
8324        );
8325    }
8326
8327    /// **`GET /login?handle=` is gated like `POST /login`.** Only the POST was
8328    /// tested; the GET form starts the same handshake and had no test, so
8329    /// deleting its gate left the suite green.
8330    #[tokio::test]
8331    async fn get_login_without_a_seat_is_refused() {
8332        let state = test_state(&[]).await;
8333        let resp = router(state)
8334            .oneshot(
8335                Request::builder()
8336                    .method("GET")
8337                    .uri("/login?handle=alice.bsky.social")
8338                    .body(Body::empty())
8339                    .unwrap(),
8340            )
8341            .await
8342            .unwrap();
8343        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8344        assert_eq!(
8345            resp.headers().get(header::LOCATION).unwrap(),
8346            "/beta/redeem"
8347        );
8348    }
8349
8350    /// A sidecar fake that answers every request `ok` and records the PATH of
8351    /// each in arrival order, plus every body — for asserting what was sent,
8352    /// and in what order.
8353    async fn spawn_logging_sidecar() -> (String, std::sync::Arc<std::sync::Mutex<Vec<String>>>) {
8354        use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
8355        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8356        let addr = listener.local_addr().unwrap();
8357        let log = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
8358        let sink = log.clone();
8359        tokio::spawn(async move {
8360            loop {
8361                let Ok((mut sock, _)) = listener.accept().await else {
8362                    break;
8363                };
8364                let mut raw: Vec<u8> = Vec::new();
8365                let mut chunk = [0u8; 4096];
8366                let text = loop {
8367                    let Ok(n) = sock.read(&mut chunk).await else {
8368                        break String::new();
8369                    };
8370                    if n == 0 {
8371                        break String::from_utf8_lossy(&raw).to_string();
8372                    }
8373                    raw.extend_from_slice(&chunk[..n]);
8374                    let Some(split) = raw.windows(4).position(|w| w == b"\r\n\r\n") else {
8375                        continue;
8376                    };
8377                    let (head, body) = raw.split_at(split + 4);
8378                    let want = String::from_utf8_lossy(head).lines().find_map(|l| {
8379                        let (k, v) = l.split_once(':')?;
8380                        k.eq_ignore_ascii_case("content-length")
8381                            .then(|| v.trim().parse::<usize>().ok())?
8382                    });
8383                    if want.is_none_or(|w| body.len() >= w) {
8384                        break String::from_utf8_lossy(&raw).to_string();
8385                    }
8386                };
8387                let path = text
8388                    .lines()
8389                    .next()
8390                    .and_then(|l| l.split_whitespace().nth(1))
8391                    .unwrap_or("")
8392                    .to_string();
8393                let body_text = text
8394                    .split_once("\r\n\r\n")
8395                    .map(|(_, b)| b)
8396                    .unwrap_or("")
8397                    .to_string();
8398                sink.lock().unwrap().push(format!("{path} {body_text}"));
8399                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();
8400                let resp = format!(
8401                    "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
8402                    body.len(),
8403                    body
8404                );
8405                let _ = sock.write_all(resp.as_bytes()).await;
8406                let _ = sock.flush().await;
8407            }
8408        });
8409        (format!("http://{addr}"), log)
8410    }
8411
8412    /// **The sign-out flush settles a split flush's landed prefix too.** It is
8413    /// `readstate::flush_did` under a timeout, so it shares the fix — pinned
8414    /// here so a sign-out path that grew its own flush would not silently lose
8415    /// it. Call 1 of 2 lands, call 2's connection drops: the landed cursors are
8416    /// created and clean, the rest stay dirty to park until the next sign-in.
8417    #[tokio::test]
8418    async fn the_sign_out_flush_settles_what_a_split_flush_landed() {
8419        use crate::readstate::tests as rs;
8420        for backend in [
8421            crate::metrics::Backend::Sidecar,
8422            crate::metrics::Backend::Rust,
8423        ] {
8424            let fake = std::sync::Arc::new(std::sync::Mutex::new(rs::FakeRepo::default()));
8425            let state = rs::state_on(backend, &fake).await;
8426            for i in 0..250 {
8427                rs::mark_read(&state, i, "1").await;
8428            }
8429            fake.lock().unwrap().drop_call = Some(2);
8430
8431            flush_before_revoke(&state, rs::DID).await;
8432
8433            let order = rs::send_order(250);
8434            let (landed, rest) = order.split_at(crate::atproto::APPLY_WRITES_MAX_OPS);
8435            for &i in landed {
8436                let c = rs::cursor(&state, i).await;
8437                assert!(c.pds_created && !c.dirty, "{backend:?}: feed {i}");
8438            }
8439            for &i in rest {
8440                let c = rs::cursor(&state, i).await;
8441                assert!(c.dirty && !c.pds_created, "{backend:?}: feed {i}");
8442            }
8443            assert_eq!(fake.lock().unwrap().apply_calls, 2, "{backend:?}");
8444        }
8445    }
8446
8447    /// **Sign-out flushes dirty read-state BEFORE it revokes — through the
8448    /// route.** The previous version of this test called
8449    /// `flush_before_revoke` and `revoke_everywhere` itself and asserted one
8450    /// flush attempt; its doc claimed deleting the call from the handler
8451    /// "drops that to zero", which was false — the handler was never run.
8452    /// Deleting the call left the suite green: #117 regressing in full, with
8453    /// the test named after it still passing. Now `POST /logout` is driven and
8454    /// the sidecar's log must show a repo write BEFORE the revoke.
8455    #[tokio::test]
8456    async fn signing_out_flushes_before_it_revokes_through_the_route() {
8457        let did = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
8458        let (sidecar, log) = spawn_logging_sidecar().await;
8459        let state = test_state_with_sidecar(&[did], &sidecar).await;
8460        crate::store::upsert_cursor(
8461            &state.db,
8462            &crate::store::ReadCursor {
8463                did: did.to_string(),
8464                feed_url: "https://example.com/feed.xml".into(),
8465                read_through: None,
8466                read_ids: "[\"1\"]".into(),
8467                unread_ids: "[]".into(),
8468                dirty: true,
8469                pds_created: false,
8470                updated_at: "2026-09-13T21:22:40Z".into(),
8471            },
8472        )
8473        .await
8474        .unwrap();
8475        let cookie = session_cookie(&state, did, None);
8476        let resp = router(state.clone())
8477            .oneshot(
8478                Request::builder()
8479                    .method("POST")
8480                    .uri("/logout")
8481                    .header(header::COOKIE, cookie)
8482                    .body(Body::empty())
8483                    .unwrap(),
8484            )
8485            .await
8486            .unwrap();
8487        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8488
8489        let entries = log.lock().unwrap().clone();
8490        let flush = entries
8491            .iter()
8492            .position(|e| e.starts_with("/internal/repo "));
8493        let revoke = entries
8494            .iter()
8495            .position(|e| e.starts_with("/internal/revoke "));
8496        assert!(revoke.is_some(), "sign-out did not revoke: {entries:?}");
8497        assert!(
8498            flush.is_some(),
8499            "sign-out did not attempt a flush before revoking: {entries:?}"
8500        );
8501        assert!(
8502            flush < revoke,
8503            "the flush arrived AFTER the revoke — no session left to send it with: {entries:?}"
8504        );
8505    }
8506
8507    /// The policy, as a literal: the backstop the router calls "neutralises any
8508    /// XSS that slips past sanitization". `script-src 'self'` and no
8509    /// `'unsafe-inline'` on it are the two clauses that make it one.
8510    const EXPECTED_CSP: &str = "default-src 'self'; \
8511     script-src 'self'; \
8512     style-src 'self' 'unsafe-inline'; \
8513     img-src 'self' https: data:; \
8514     font-src 'self'; \
8515     connect-src 'self'; \
8516     form-action 'self'; \
8517     base-uri 'self'; \
8518     frame-ancestors 'none'; \
8519     object-src 'none'";
8520
8521    /// Build a `multipart/form-data` body carrying a single `file` field whose
8522    /// contents are `payload`, returning `(content_type, body_bytes)`.
8523    fn opml_multipart(payload: &[u8]) -> (String, Vec<u8>) {
8524        let boundary = "----featherreadertestboundary";
8525        let mut body = Vec::new();
8526        body.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
8527        body.extend_from_slice(
8528            b"Content-Disposition: form-data; name=\"file\"; filename=\"feeds.opml\"\r\n",
8529        );
8530        body.extend_from_slice(b"Content-Type: text/x-opml\r\n\r\n");
8531        body.extend_from_slice(payload);
8532        body.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes());
8533        (format!("multipart/form-data; boundary={boundary}"), body)
8534    }
8535
8536    #[tokio::test]
8537    async fn opml_import_oversize_upload_returns_413() {
8538        let state = test_state(&["did:plc:admin"]).await;
8539        let cookie = session_cookie(&state, "did:plc:admin", None);
8540        let app = router(state);
8541
8542        // A payload comfortably above the route cap.
8543        let payload = vec![b'a'; OPML_BODY_LIMIT + 1024];
8544        let (content_type, body) = opml_multipart(&payload);
8545
8546        let resp = app
8547            .oneshot(
8548                Request::builder()
8549                    .method("POST")
8550                    .uri("/opml")
8551                    .header("content-type", content_type)
8552                    .header(header::COOKIE, cookie)
8553                    .body(Body::from(body))
8554                    .unwrap(),
8555            )
8556            .await
8557            .unwrap();
8558        assert_eq!(
8559            resp.status(),
8560            StatusCode::PAYLOAD_TOO_LARGE,
8561            "an over-cap OPML upload must be rejected with 413, not collapsed to 500"
8562        );
8563    }
8564
8565    /// **The route's own cap is what refuses this, not the framework's.**
8566    ///
8567    /// `OPML_BODY_LIMIT` used to equal axum's `DefaultBodyLimit` (2 MiB), so
8568    /// the route's layer was a no-op — deleting it left every test green, and
8569    /// `opml_import_oversize_upload_returns_413` was really testing axum. The
8570    /// limit is 1 MiB now, strictly tighter, and this uploads a payload that
8571    /// sits BETWEEN the two: over ours, under the framework's. Only the
8572    /// route's layer can refuse it — remove the layer and this payload is
8573    /// accepted, which is also what demonstrates the framework's default is
8574    /// the larger of the two.
8575    #[tokio::test]
8576    async fn opml_import_over_the_route_cap_is_refused_below_the_framework_default() {
8577        let state = test_state(&["did:plc:admin"]).await;
8578        let cookie = session_cookie(&state, "did:plc:admin", None);
8579        let app = router(state);
8580
8581        // Between the two ceilings: the framework would accept this.
8582        let payload = vec![b'a'; (OPML_BODY_LIMIT + AXUM_DEFAULT_BODY_LIMIT) / 2];
8583        let (content_type, body) = opml_multipart(&payload);
8584
8585        let resp = app
8586            .oneshot(
8587                Request::builder()
8588                    .method("POST")
8589                    .uri("/opml")
8590                    .header("content-type", content_type)
8591                    .header(header::COOKIE, cookie)
8592                    .body(Body::from(body))
8593                    .unwrap(),
8594            )
8595            .await
8596            .unwrap();
8597        assert_eq!(
8598            resp.status(),
8599            StatusCode::PAYLOAD_TOO_LARGE,
8600            "a payload over the route's cap but under the framework's was accepted — \
8601             the route's own DefaultBodyLimit layer is not doing anything"
8602        );
8603    }
8604
8605    #[tokio::test]
8606    async fn opml_import_under_limit_upload_is_accepted() {
8607        let state = test_state(&["did:plc:admin"]).await;
8608        let cookie = session_cookie(&state, "did:plc:admin", None);
8609        let db = state.db.clone();
8610        let app = router(state);
8611
8612        // A small, valid OPML well under the cap: must be accepted (the handler
8613        // redirects to `/` or a flash), i.e. never 413.
8614        let opml = br#"<?xml version="1.0"?>
8615<opml version="2.0"><body>
8616  <outline text="Example" type="rss" xmlUrl="https://example.com/feed.xml"/>
8617</body></opml>"#;
8618        let (content_type, body) = opml_multipart(opml);
8619
8620        let resp = app
8621            .oneshot(
8622                Request::builder()
8623                    .method("POST")
8624                    .uri("/opml")
8625                    .header("content-type", content_type)
8626                    .header(header::COOKIE, cookie)
8627                    .body(Body::from(body))
8628                    .unwrap(),
8629            )
8630            .await
8631            .unwrap();
8632        // **Assert it was ACCEPTED, not merely that it was not a 413.**
8633        //
8634        // The old assertion was `assert_ne!(status, PAYLOAD_TOO_LARGE)`, which a
8635        // 500 satisfies — so making `import_opml` fail unconditionally left this
8636        // green. Three other OPML tests caught that mutation; the one whose name
8637        // promises to cover the under-cap case did not.
8638        assert_eq!(
8639            resp.status(),
8640            StatusCode::SEE_OTHER,
8641            "an under-cap OPML upload was not accepted (status {})",
8642            resp.status(),
8643        );
8644        // **303 alone is not acceptance.** `import_opml` redirects on several
8645        // FAILURES too — unparseable OPML, zero feeds found, every feed trimmed
8646        // by a cap — so an import that stored nothing satisfied the status check.
8647        let stored: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM feeds WHERE url = ?1")
8648            .bind("https://example.com/feed.xml")
8649            .fetch_one(&db)
8650            .await
8651            .unwrap();
8652        assert_eq!(stored, 1, "the upload was redirected but imported nothing");
8653        let location = resp
8654            .headers()
8655            .get(header::LOCATION)
8656            .and_then(|v| v.to_str().ok())
8657            .unwrap_or_default()
8658            .to_string();
8659        assert!(
8660            !location.starts_with("/login"),
8661            "the import bounced to login instead of being accepted: {location}",
8662        );
8663    }
8664
8665    #[tokio::test]
8666    async fn opml_import_logged_out_redirects_to_login() {
8667        // Logged-out callers are redirected before the body is consumed; assert
8668        // the auth short-circuit rather than a body-cap rejection.
8669        let state = test_state(&["did:plc:admin"]).await;
8670        let app = router(state);
8671
8672        let opml = b"<opml version=\"2.0\"><body></body></opml>";
8673        let (content_type, body) = opml_multipart(opml);
8674
8675        let resp = app
8676            .oneshot(
8677                Request::builder()
8678                    .method("POST")
8679                    .uri("/opml")
8680                    .header("content-type", content_type)
8681                    .body(Body::from(body))
8682                    .unwrap(),
8683            )
8684            .await
8685            .unwrap();
8686        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8687        assert_eq!(resp.headers().get(header::LOCATION).unwrap(), "/login");
8688    }
8689
8690    // -- delete-my-data (POST /account/delete) --------------------------------
8691
8692    /// A one-shot mock sidecar: binds a loopback port, answers exactly one
8693    /// `POST /internal/revoke` with `{ok:true,…}`, and reports (via the returned
8694    /// channel) the DID it was asked to revoke. Enough to prove the delete
8695    /// handler triggers the sidecar revoke without pulling in an HTTP-mock crate.
8696    async fn spawn_revoke_sidecar() -> (String, tokio::sync::oneshot::Receiver<String>) {
8697        use tokio::io::{AsyncReadExt, AsyncWriteExt};
8698        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8699        let addr = listener.local_addr().unwrap();
8700        let (tx, rx) = tokio::sync::oneshot::channel::<String>();
8701        tokio::spawn(async move {
8702            let (mut sock, _) = listener.accept().await.unwrap();
8703            let mut buf = vec![0u8; 4096];
8704            let n = sock.read(&mut buf).await.unwrap();
8705            let req = String::from_utf8_lossy(&buf[..n]).to_string();
8706            // Pull the DID out of the JSON body (last line of the request).
8707            let did = req
8708                .split("\r\n\r\n")
8709                .nth(1)
8710                .and_then(|body| {
8711                    let v: serde_json::Value = serde_json::from_str(body.trim()).ok()?;
8712                    v.get("did")?.as_str().map(str::to_string)
8713                })
8714                .unwrap_or_default();
8715            let is_revoke = req.starts_with("POST /internal/revoke");
8716            let body = serde_json::json!({
8717                "ok": true, "did": did, "revoked": true, "hadSession": true
8718            })
8719            .to_string();
8720            let resp = format!(
8721                "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
8722                body.len(),
8723                body
8724            );
8725            sock.write_all(resp.as_bytes()).await.unwrap();
8726            sock.flush().await.unwrap();
8727            let _ = tx.send(if is_revoke { did } else { String::new() });
8728        });
8729        (format!("http://{addr}"), rx)
8730    }
8731
8732    /// Build an [`AppState`] whose sidecar points at `sidecar_url`.
8733    async fn test_state_with_sidecar(allowed: &[&str], sidecar_url: &str) -> AppState {
8734        let defaults = Config::default();
8735        test_state_with_sidecar_and(
8736            allowed,
8737            sidecar_url,
8738            defaults.standard_site,
8739            defaults.max_feeds_global,
8740        )
8741        .await
8742    }
8743
8744    /// [`test_state_with_sidecar`] with the standard.site flag and the global
8745    /// feeds ceiling chosen — the two settings the at:// paths branch on.
8746    async fn test_state_with_sidecar_and(
8747        allowed: &[&str],
8748        sidecar_url: &str,
8749        standard_site: bool,
8750        max_feeds_global: i64,
8751    ) -> AppState {
8752        let db = store::init_url("sqlite::memory:").await.unwrap();
8753        let dids: Vec<String> = allowed.iter().map(|s| s.to_string()).collect();
8754        store::ensure_seed(&db, &dids).await.unwrap();
8755        let mut config = Config {
8756            allowed_dids: dids,
8757            cookie_secret: "test-cookie-secret-000".to_string(),
8758            beta_cap: 3,
8759            standard_site,
8760            max_feeds_global,
8761            ..Config::default()
8762        };
8763        config.sidecar.public_url = sidecar_url.to_string();
8764        config.sidecar.internal_url = sidecar_url.to_string();
8765        AppState::new(config, db).unwrap()
8766    }
8767
8768    /// A confirmed `POST /account/delete` purges the caller's local rows, calls
8769    /// the sidecar revoke for that DID, and clears the session cookie.
8770    #[tokio::test]
8771    async fn account_delete_purges_rows_and_triggers_revoke() {
8772        let (sidecar_url, revoke_rx) = spawn_revoke_sidecar().await;
8773        let did = "did:plc:leaver";
8774        let state = test_state_with_sidecar(&[], &sidecar_url).await;
8775
8776        // Seed the DID with local rows across the per-DID tables.
8777        store::grant_access(&state.db, did, Some("leaver.example"), "test", None)
8778            .await
8779            .unwrap();
8780        store::replace_sub_refs(&state.db, did, &[]).await.unwrap();
8781        store::mint_code(&state.db, did, 3600).await.unwrap();
8782        assert!(store::has_beta_access(&state.db, did).await.unwrap());
8783
8784        let cookie = session_cookie(&state, did, Some("leaver.example"));
8785        let app = router(state.clone());
8786
8787        let resp = app
8788            .oneshot(
8789                Request::builder()
8790                    .method("POST")
8791                    .uri("/account/delete")
8792                    .header(header::COOKIE, cookie)
8793                    .header("content-type", "application/x-www-form-urlencoded")
8794                    .body(Body::from("confirm=DELETE"))
8795                    .unwrap(),
8796            )
8797            .await
8798            .unwrap();
8799
8800        // Signed out: redirect to /login with the cookie cleared.
8801        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8802        assert!(resp
8803            .headers()
8804            .get(header::LOCATION)
8805            .unwrap()
8806            .to_str()
8807            .unwrap()
8808            .starts_with("/login"));
8809        let set_cookie = resp
8810            .headers()
8811            .get(header::SET_COOKIE)
8812            .unwrap()
8813            .to_str()
8814            .unwrap();
8815        assert!(set_cookie.contains("Max-Age=0"), "cookie must be cleared");
8816
8817        // The sidecar revoke was called for exactly this DID.
8818        //
8819        // BOUNDED. A bare `await` here meant a broken `revoke_everywhere` — one
8820        // that simply never called the sidecar — hung this test forever instead
8821        // of failing it: a wedged CI job rather than a red one, which is the
8822        // worse of the two signals because nobody reads it as a defect.
8823        let revoked_did = tokio::time::timeout(std::time::Duration::from_secs(10), revoke_rx)
8824            .await
8825            .expect("the sidecar revoke never fired; revoke_everywhere did not call it")
8826            .unwrap();
8827        assert_eq!(
8828            revoked_did, did,
8829            "sidecar revoke must fire for the caller DID"
8830        );
8831
8832        // Local rows are gone.
8833        assert!(!store::has_beta_access(&state.db, did).await.unwrap());
8834        let codes: i64 =
8835            sqlx::query_scalar("SELECT COUNT(*) FROM invite_codes WHERE creator_did = ?1")
8836                .bind(did)
8837                .fetch_one(&state.db)
8838                .await
8839                .unwrap();
8840        assert_eq!(codes, 0);
8841    }
8842
8843    /// An UN-confirmed `POST /account/delete` (wrong/blank `confirm`) deletes
8844    /// nothing and bounces back to /manage.
8845    #[tokio::test]
8846    async fn account_delete_without_confirm_is_a_noop() {
8847        let did = "did:plc:staying";
8848        let state = test_state(&[]).await;
8849        store::grant_access(&state.db, did, None, "test", None)
8850            .await
8851            .unwrap();
8852        let cookie = session_cookie(&state, did, None);
8853        let app = router(state.clone());
8854
8855        let resp = app
8856            .oneshot(
8857                Request::builder()
8858                    .method("POST")
8859                    .uri("/account/delete")
8860                    .header(header::COOKIE, cookie)
8861                    .header("content-type", "application/x-www-form-urlencoded")
8862                    .body(Body::from("confirm=nope"))
8863                    .unwrap(),
8864            )
8865            .await
8866            .unwrap();
8867
8868        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8869        assert!(resp
8870            .headers()
8871            .get(header::LOCATION)
8872            .unwrap()
8873            .to_str()
8874            .unwrap()
8875            .starts_with("/manage"));
8876        // Nothing deleted.
8877        assert!(store::has_beta_access(&state.db, did).await.unwrap());
8878    }
8879
8880    /// PDS-outage authorization: when the sidecar is unreachable (as it is in
8881    /// this harness — the default sidecar URL is not served), a DID must STILL
8882    /// be unable to read or mutate an entry in a feed it does not subscribe to.
8883    /// This guards the `resolve_subscriptions` fallback: it must fail CLOSED
8884    /// (serve only the DID's own `sub_ref`), never widen the caller's surface to
8885    /// every cached feed.
8886    #[tokio::test]
8887    async fn pds_outage_does_not_widen_cross_did_access() {
8888        let did_a = "did:plc:aaaa";
8889        let state = test_state(&[]).await;
8890        store::grant_access(&state.db, did_a, None, "test", None)
8891            .await
8892            .unwrap();
8893
8894        // Shared cache: feed_a (A subscribes) + feed_b (A does NOT). An entry
8895        // lives in feed_b — the one A must never touch during the outage.
8896        let feed_a = store::upsert_feed(
8897            &state.db,
8898            &store::NewFeed {
8899                url: "https://a.example/feed.xml".to_string(),
8900                title: Some("A".to_string()),
8901                ..Default::default()
8902            },
8903        )
8904        .await
8905        .unwrap();
8906        let feed_b = store::upsert_feed(
8907            &state.db,
8908            &store::NewFeed {
8909                url: "https://b.example/feed.xml".to_string(),
8910                title: Some("B".to_string()),
8911                ..Default::default()
8912            },
8913        )
8914        .await
8915        .unwrap();
8916        store::insert_entries(
8917            &state.db,
8918            feed_b,
8919            &[store::NewEntry {
8920                guid: "b-1".to_string(),
8921                url: Some("https://b.example/1".to_string()),
8922                title: Some("B one".to_string()),
8923                published: Some("2026-07-11T00:00:00Z".to_string()),
8924                content_html: Some("<p>secret B body</p>".to_string()),
8925                ..Default::default()
8926            }],
8927            0,
8928        )
8929        .await
8930        .unwrap();
8931        // A subscribes ONLY to feed_a.
8932        store::replace_sub_refs(&state.db, did_a, &[feed_a])
8933            .await
8934            .unwrap();
8935        // Read B's entry id via a transient sub_ref, then drop it so only the
8936        // shared cache holds B's entry (no DID subscribes to feed_b anymore).
8937        store::replace_sub_refs(&state.db, "did:plc:bbbb", &[feed_b])
8938            .await
8939            .unwrap();
8940        let b_entry_id = store::entries_for_feed(&state.db, "did:plc:bbbb", feed_b)
8941            .await
8942            .unwrap()[0]
8943            .id;
8944        store::replace_sub_refs(&state.db, "did:plc:bbbb", &[])
8945            .await
8946            .unwrap();
8947
8948        let cookie = session_cookie(&state, did_a, None);
8949        let app = router(state.clone());
8950
8951        // GET /entries/{b} as A → 404 even during the outage.
8952        let get_b = app
8953            .clone()
8954            .oneshot(
8955                Request::builder()
8956                    .method("GET")
8957                    .uri(format!("/entries/{b_entry_id}"))
8958                    .header(header::COOKIE, cookie.clone())
8959                    .body(Body::empty())
8960                    .unwrap(),
8961            )
8962            .await
8963            .unwrap();
8964        assert_eq!(
8965            get_b.status(),
8966            StatusCode::NOT_FOUND,
8967            "A must not read B's entry during a PDS outage"
8968        );
8969
8970        // POST /entries/{b}/read as A → 404, and no entry_state row is written.
8971        let read_b = app
8972            .oneshot(
8973                Request::builder()
8974                    .method("POST")
8975                    .uri(format!("/entries/{b_entry_id}/read"))
8976                    .header(header::COOKIE, cookie)
8977                    .header("content-type", "application/x-www-form-urlencoded")
8978                    .body(Body::from("read=true"))
8979                    .unwrap(),
8980            )
8981            .await
8982            .unwrap();
8983        assert_eq!(
8984            read_b.status(),
8985            StatusCode::NOT_FOUND,
8986            "A must not mark B's entry read during a PDS outage"
8987        );
8988
8989        // The fallback must NOT have widened A's sub_ref to feed_b.
8990        let a_feed_ids: Vec<i64> = sqlx::query_scalar("SELECT feed_id FROM sub_ref WHERE did = ?1")
8991            .bind(did_a)
8992            .fetch_all(&state.db)
8993            .await
8994            .unwrap();
8995        assert_eq!(
8996            a_feed_ids,
8997            vec![feed_a],
8998            "outage fallback must not add feeds A never subscribed to"
8999        );
9000        // And B's entry has zero read-state (A's attempt did not mutate).
9001        let es_count: i64 =
9002            sqlx::query_scalar("SELECT COUNT(*) FROM entry_state WHERE did = ?1 AND entry_id = ?2")
9003                .bind(did_a)
9004                .bind(b_entry_id)
9005                .fetch_one(&state.db)
9006                .await
9007                .unwrap();
9008        assert_eq!(es_count, 0, "no cross-DID mutation during the outage");
9009    }
9010
9011    /// **A logout with nothing to revoke is a SUCCESS — on the arm that had
9012    /// nothing. The other arm is counted separately.**
9013    ///
9014    /// Logout is idempotent, so `NoSession` must record as ok; counting it as an
9015    /// error would make the metric noisy in exactly the case that is fine.
9016    ///
9017    /// But `revoke_everywhere` has TWO arms, and a review found that counting
9018    /// only the rust one let `oauth_revoke` report all-clear while every sidecar
9019    /// revocation failed. For anyone who logged in before the cutover the sidecar
9020    /// store is the only one holding tokens, so the rust arm correctly says
9021    /// NoSession and the metric said nothing was wrong. Both arms are now
9022    /// recorded, distinguished by the backend column — so this test pins the
9023    /// BACKEND as well as the outcome.
9024    #[tokio::test]
9025    async fn a_logout_with_no_session_counts_as_success() {
9026        let did = "did:plc:aaaa";
9027        let state = test_state(&[]).await;
9028        assert!(
9029            state.oauth.is_some(),
9030            "meaningless without an oauth runtime; the revoke arm would be skipped",
9031        );
9032
9033        revoke_everywhere(&state, did).await;
9034        let rows = state.metrics.snapshot();
9035        let find = |b: crate::metrics::Backend| {
9036            rows.iter()
9037                .find(|r| r.op == "oauth_revoke" && r.backend == b)
9038                .unwrap_or_else(|| panic!("no oauth_revoke row for {b:?}"))
9039        };
9040
9041        // Rust arm: nothing stored for this DID, so NoSession -> ok.
9042        let rust = find(crate::metrics::Backend::Rust);
9043        assert_eq!(
9044            rust.stats.err_count, 0,
9045            "NoSession was counted as a failure; logout is idempotent",
9046        );
9047        assert_eq!(rust.stats.ok_count, 1);
9048
9049        // Sidecar arm: unreachable in a test, so it must be recorded as an
9050        // ERROR under its own backend — not silently dropped, and not folded
9051        // into the rust row.
9052        let sidecar = find(crate::metrics::Backend::Sidecar);
9053        assert_eq!(
9054            sidecar.stats.err_count, 1,
9055            "a failed sidecar revoke was not counted",
9056        );
9057    }
9058
9059    /// **`Failed` must count as an error — the half the metric exists for.**
9060    ///
9061    /// A review found this unpinned: replacing the mapping with
9062    /// `let revoke_ok = true;` passed all 682 tests. The only revoke test
9063    /// asserted the `NoSession -> ok` half, so the branch that actually means
9064    /// "the PDS still holds tokens we asked it to drop" was untested.
9065    ///
9066    /// Driven through the same handler, with a session present but the PDS
9067    /// unreachable, so `sign_out_discovering` returns `Failed`.
9068    #[tokio::test]
9069    async fn a_failed_rust_revoke_counts_as_an_error() {
9070        let did = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
9071        let state = test_state(&[]).await;
9072        let runtime = state.oauth.as_deref().expect("oauth runtime");
9073        crate::oauth::store::put_session(
9074            &state.db,
9075            &runtime.codec,
9076            &crate::oauth::store::OAuthSession {
9077                sub: did.into(),
9078                issuer: "https://auth.invalid".into(),
9079                aud: "https://pds.invalid".into(),
9080                dpop_key_jwk: crate::oauth::keys::SigningKey::generate("session-dpop")
9081                    .to_jwk_json()
9082                    .unwrap(),
9083                access_token: "at".into(),
9084                refresh_token: "rt".into(),
9085                token_type: "DPoP".into(),
9086                granted_scope: "atproto".into(),
9087                expires_at: Some(crate::store::now_unix() + 3600),
9088            },
9089        )
9090        .await
9091        .unwrap();
9092
9093        revoke_everywhere(&state, did).await;
9094
9095        let rows = state.metrics.snapshot();
9096        let rust = rows
9097            .iter()
9098            .find(|r| r.op == "oauth_revoke" && r.backend == crate::metrics::Backend::Rust)
9099            .expect("no rust oauth_revoke row");
9100        assert_eq!(
9101            rust.stats.err_count, 1,
9102            "an unreachable PDS must count as a revocation failure",
9103        );
9104        assert_eq!(rust.stats.ok_count, 0);
9105    }
9106
9107    /// **The `href` defence is now carried by the TYPE, not by remembering.**
9108    ///
9109    /// `EntryRow.link` used to be a `String`, and the guard was "call
9110    /// `net::safe_link` before assigning it". Deleting that call left all 679
9111    /// tests passing — a live XSS defence with nothing protecting it.
9112    ///
9113    /// `SafeLink` has no `From<String>` and no public member, so the only way to
9114    /// get foreign input into an `href` is `external`, which does the check
9115    /// itself. This test pins that constructor; the *wiring* is now pinned by
9116    /// the compiler, which is the part a test could never hold down.
9117    ///
9118    /// Note the empty-not-absent behaviour: a rejected URL yields an EMPTY link
9119    /// so the template renders the row WITHOUT an anchor. Dropping the row
9120    /// instead would make the record unremovable, because the un-save button
9121    /// lives on it.
9122    #[test]
9123    fn a_hostile_scheme_cannot_reach_an_href_through_safelink() {
9124        for hostile in [
9125            "javascript:alert(1)",
9126            "JavaScript:alert(1)",
9127            "  javascript:alert(1)",
9128            "data:text/html;base64,PHNjcmlwdD4=",
9129            "vbscript:msgbox(1)",
9130            "file:///etc/passwd",
9131            // Protocol-relative: inherits the page's scheme, so it is an
9132            // off-site link wearing a same-site costume. Carried over from the
9133            // test this one replaces, which was its only unique input.
9134            "//evil.example/path",
9135        ] {
9136            let link = SafeLink::external(hostile);
9137            assert!(
9138                link.is_empty(),
9139                "{hostile:?} produced a non-empty href: {link}",
9140            );
9141            assert!(
9142                !link.to_string().to_ascii_lowercase().contains("script"),
9143                "{hostile:?} leaked into the rendered link",
9144            );
9145        }
9146
9147        // And the other direction: a check that rejects everything would satisfy
9148        // the loop above while breaking every real saved record.
9149        for good in ["https://example.com/a?b=c#d", "http://example.com/"] {
9150            let link = SafeLink::external(good);
9151            assert!(!link.is_empty(), "{good:?} was wrongly rejected");
9152            assert_eq!(link.to_string(), good);
9153        }
9154    }
9155
9156    /// **The WIRING, not the helper — this is the one that catches the real
9157    /// mistake.**
9158    ///
9159    /// `a_hostile_scheme_cannot_reach_an_href_through_safelink` pins what
9160    /// `SafeLink::external` *does*. It cannot pin that the saved-record path
9161    /// *calls* it, and a review proved that gap was live twice over: swapping
9162    /// `external` for the app-path constructor, and constructing the tuple
9163    /// directly, both restored the whole `javascript:` hole with every test
9164    /// green. The type now blocks both — `entry` takes an `i64`, and the field
9165    /// lives in another module — but the wiring deserves a test of its own
9166    /// rather than resting on the shape of a signature.
9167    ///
9168    /// Renders the actual row through the actual handler, from a record whose
9169    /// URL is hostile.
9170    #[tokio::test]
9171    async fn a_saved_record_with_a_hostile_url_renders_no_anchor() {
9172        let did = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
9173        let sidecar = spawn_saved_sidecar("javascript:alert(1)", "Hostile record").await;
9174        let mut state = test_state_with_sidecar(&[did], &sidecar).await;
9175        std::sync::Arc::get_mut(&mut state.config).unwrap().dev_did = Some(did.to_string());
9176
9177        let resp = router(state)
9178            .oneshot(
9179                Request::builder()
9180                    .uri("/?view=starred")
9181                    .body(Body::empty())
9182                    .unwrap(),
9183            )
9184            .await
9185            .unwrap();
9186        assert_eq!(resp.status(), StatusCode::OK);
9187        let body = String::from_utf8(
9188            axum::body::to_bytes(resp.into_body(), usize::MAX)
9189                .await
9190                .unwrap()
9191                .to_vec(),
9192        )
9193        .unwrap();
9194
9195        // Not in an href, and not as the title either — the title falls back to
9196        // the URL for links we DO render, so both paths must withhold it.
9197        assert!(
9198            !body.to_ascii_lowercase().contains("javascript:"),
9199            "the hostile scheme reached the rendered page",
9200        );
9201        // But the row must survive: the un-save button lives on it, so dropping
9202        // the row would make the record unremovable from here.
9203        assert!(
9204            body.contains("unusable link"),
9205            "the row was dropped instead of rendering without an anchor",
9206        );
9207    }
9208
9209    /// **The reader view's two `href`s, through the actual handler.**
9210    ///
9211    /// The sibling above covers the LIST row. `entry.html` has its own pair of
9212    /// `href`s fed by `EntryTemplate.url`, and they were a raw `Option<String>`
9213    /// taken straight off the `entries.url` column — a remote feed's `<link>`.
9214    ///
9215    /// Ingest already scheme-checks that column (`feed.rs`'s `entry_link`), so
9216    /// this was never a live hole. But that guard is procedural and sits a long
9217    /// way from the `href`: it holds only as long as every future writer to
9218    /// `entries.url` remembers to go through `feed.rs`. This test does not
9219    /// depend on it — it writes the hostile URL into the column DIRECTLY, which
9220    /// is precisely the state the ingest check cannot speak for.
9221    ///
9222    /// **Both directions, deliberately.** A fix that renders no link at all
9223    /// satisfies every negative assertion here, and would break every real
9224    /// entry. The second half is what makes the first half mean something.
9225    #[tokio::test]
9226    async fn a_hostile_entry_url_renders_the_reader_without_an_original_link() {
9227        let did = "did:plc:readerhref";
9228        let state = test_state(&[]).await;
9229        store::grant_access(&state.db, did, None, "test", None)
9230            .await
9231            .unwrap();
9232        let feed = store::upsert_feed(
9233            &state.db,
9234            &store::NewFeed {
9235                url: "https://href.example/feed.xml".to_string(),
9236                title: Some("Href".to_string()),
9237                ..Default::default()
9238            },
9239        )
9240        .await
9241        .unwrap();
9242        // Straight into the column, bypassing `feed.rs` — the whole point.
9243        store::insert_entries(
9244            &state.db,
9245            feed,
9246            &[
9247                store::NewEntry {
9248                    guid: "hostile-1".to_string(),
9249                    url: Some("javascript:alert(1)".to_string()),
9250                    title: Some("Hostile entry".to_string()),
9251                    published: Some("2026-07-11T00:00:00Z".to_string()),
9252                    ..Default::default()
9253                },
9254                store::NewEntry {
9255                    guid: "benign-1".to_string(),
9256                    url: Some("https://href.example/post".to_string()),
9257                    title: Some("Benign entry".to_string()),
9258                    published: Some("2026-07-10T00:00:00Z".to_string()),
9259                    ..Default::default()
9260                },
9261            ],
9262            0,
9263        )
9264        .await
9265        .unwrap();
9266        store::replace_sub_refs(&state.db, did, &[feed])
9267            .await
9268            .unwrap();
9269        let rows = store::entries_for_feed(&state.db, did, feed).await.unwrap();
9270        let id_of = |guid: &str| {
9271            rows.iter()
9272                .find(|r| r.guid == guid)
9273                .unwrap_or_else(|| panic!("{guid} was not inserted"))
9274                .id
9275        };
9276
9277        let cookie = session_cookie(&state, did, None);
9278        let app = router(state.clone());
9279
9280        let render = |id: i64| {
9281            let app = app.clone();
9282            let cookie = cookie.clone();
9283            async move {
9284                let resp = app
9285                    .oneshot(
9286                        Request::builder()
9287                            .method("GET")
9288                            .uri(format!("/entries/{id}"))
9289                            .header(header::COOKIE, cookie)
9290                            .body(Body::empty())
9291                            .unwrap(),
9292                    )
9293                    .await
9294                    .unwrap();
9295                assert_eq!(resp.status(), StatusCode::OK);
9296                String::from_utf8(
9297                    axum::body::to_bytes(resp.into_body(), usize::MAX)
9298                        .await
9299                        .unwrap()
9300                        .to_vec(),
9301                )
9302                .unwrap()
9303            }
9304        };
9305
9306        let hostile = render(id_of("hostile-1")).await;
9307        // The reader page for THIS entry actually rendered. Without this the
9308        // three negatives below are satisfied by an empty body.
9309        assert!(
9310            hostile.contains("Hostile entry"),
9311            "the reader did not render the entry: {hostile}",
9312        );
9313        assert!(
9314            !hostile.to_ascii_lowercase().contains("javascript:"),
9315            "the hostile scheme reached the reader page: {hostile}",
9316        );
9317        // Not merely escaped — the template took its no-link branch. Both
9318        // `href`s are gated on the same `Option`, so this covers the byline
9319        // link and the action-bar button together.
9320        assert!(
9321            !hostile.contains("actionbar-open"),
9322            "the action bar rendered an open-original link for a refused URL: {hostile}",
9323        );
9324        assert!(
9325            !hostile.contains("Original \u{2197}"),
9326            "the byline rendered an original link for a refused URL: {hostile}",
9327        );
9328
9329        // The other direction: a legitimate entry still links out, so "render
9330        // nothing" cannot pass as a fix.
9331        let benign = render(id_of("benign-1")).await;
9332        assert!(
9333            benign.contains("Benign entry"),
9334            "the reader did not render the benign entry: {benign}",
9335        );
9336        // BOTH `href`s, counted. The negatives above fire on the action bar
9337        // first, so without this the byline needle `Original \u{2197}` is never
9338        // once observed failing — a misspelled needle would pass forever.
9339        assert_eq!(
9340            benign
9341                .matches(r#"href="https://href.example/post""#)
9342                .count(),
9343            2,
9344            "entry.html has two `href`s for the entry URL — the byline link and \
9345             the action-bar button — and this render produced a different \
9346             number: {benign}",
9347        );
9348        assert!(
9349            benign.contains("actionbar-open"),
9350            "a legitimate entry lost its open-original button: {benign}",
9351        );
9352        assert!(
9353            benign.contains("Original \u{2197}"),
9354            "a legitimate entry lost its byline link: {benign}",
9355        );
9356    }
9357
9358    /// **The outage fallback must not widen what the caller can READ — and the
9359    /// sibling test above can only see what it WRITES.**
9360    ///
9361    /// `pds_outage_does_not_widen_cross_did_access` asserts on `sub_ref` rows and
9362    /// on `entry_state`: the fallback's side effects. But the fail-open it names
9363    /// returns **early**, before `sync_sub_refs` runs, so it touches neither. It
9364    /// leaks through the list it *hands back* — the sidebar and the reader render
9365    /// from that list, so a caller sees another DID's feeds while `sub_ref` stays
9366    /// perfectly honest and every existing assertion stays green.
9367    ///
9368    /// Measured, not assumed: replacing `feeds_for_did(pool, did)` with
9369    /// `due_feeds(pool, "9999-…", 10_000)` over the whole shared cache — the
9370    /// exact historical bug the fallback's comment describes — left **all 663
9371    /// tests passing**. Cross-tenant isolation is the one property this project
9372    /// cannot regress quietly, and nothing observed it.
9373    ///
9374    /// So this asserts on the RETURN VALUE, which is the thing that reaches the
9375    /// user, and it deliberately does not look at `sub_ref` at all — that half is
9376    /// already covered above.
9377    #[tokio::test]
9378    async fn the_outage_fallback_returns_only_the_callers_own_feeds() {
9379        let did_a = "did:plc:aaaa";
9380        let state = test_state(&[]).await;
9381        store::grant_access(&state.db, did_a, None, "test", None)
9382            .await
9383            .unwrap();
9384
9385        let feed_a = store::upsert_feed(
9386            &state.db,
9387            &store::NewFeed {
9388                url: "https://a.example/feed.xml".to_string(),
9389                title: Some("A".to_string()),
9390                ..Default::default()
9391            },
9392        )
9393        .await
9394        .unwrap();
9395        let _feed_b = store::upsert_feed(
9396            &state.db,
9397            &store::NewFeed {
9398                url: "https://b.example/feed.xml".to_string(),
9399                title: Some("B".to_string()),
9400                ..Default::default()
9401            },
9402        )
9403        .await
9404        .unwrap();
9405        // A subscribes ONLY to feed_a. feed_b is in the shared cache and belongs
9406        // to nobody — exactly the row a whole-cache fallback would hand to A.
9407        store::replace_sub_refs(&state.db, did_a, &[feed_a])
9408            .await
9409            .unwrap();
9410
9411        // No sidecar and no PDS are reachable from a test, so
9412        // `list_subscriptions_sorted` fails and this IS the outage path. Assert
9413        // that, rather than assuming it: if the repo ever starts succeeding here,
9414        // this test would silently stop exercising the fallback at all.
9415        assert!(
9416            state.repo().list_subscriptions_sorted(did_a).await.is_err(),
9417            "this test is only meaningful on the outage path; the repo answered",
9418        );
9419
9420        let resolved = resolve_subscriptions(&state, did_a).await;
9421
9422        let urls: Vec<&str> = resolved.iter().map(|r| r.sub.url.as_str()).collect();
9423        assert_eq!(
9424            urls,
9425            vec!["https://a.example/feed.xml"],
9426            "the outage fallback must return the caller's OWN subscriptions only; \
9427             any other feed here is cross-tenant read access granted by an outage",
9428        );
9429    }
9430
9431    /// Build an [`AppState`] over a fresh in-memory DB with explicit feed caps,
9432    /// seeding `did` a beta seat + session-capable state.
9433    async fn test_state_with_caps(
9434        did: &str,
9435        max_subs_per_did: i64,
9436        max_feeds_global: i64,
9437    ) -> AppState {
9438        let db = store::init_url("sqlite::memory:").await.unwrap();
9439        let config = Config {
9440            cookie_secret: "test-cookie-secret-000".to_string(),
9441            beta_cap: 100,
9442            max_subs_per_did,
9443            max_feeds_global,
9444            ..Config::default()
9445        };
9446        store::grant_access(&db, did, None, "test", None)
9447            .await
9448            .unwrap();
9449        AppState::new(config, db).unwrap()
9450    }
9451
9452    /// An OPML document with `n` distinct public feeds.
9453    fn opml_with_feeds(n: usize) -> String {
9454        let mut outlines = String::new();
9455        for i in 0..n {
9456            outlines.push_str(&format!(
9457                "<outline type=\"rss\" text=\"F{i}\" xmlUrl=\"https://f{i}.example/feed.xml\"/>\n"
9458            ));
9459        }
9460        format!(
9461            "<?xml version=\"1.0\"?>\n<opml version=\"2.0\"><head><title>t</title></head><body>\n{outlines}</body></opml>"
9462        )
9463    }
9464
9465    /// OPML bulk import must honour the GLOBAL feeds ceiling: importing more
9466    /// distinct new feeds than the shared cache can hold caches only up to the
9467    /// ceiling — the rest are trimmed. (Regression: the import loop previously
9468    /// bypassed `max_feeds_global` entirely.)
9469    #[tokio::test]
9470    async fn opml_import_enforces_global_feeds_ceiling() {
9471        let did = "did:plc:importer";
9472        // Cap the shared cache at 3 feeds; import 10 distinct new ones.
9473        let state = test_state_with_caps(did, 0, 3).await;
9474        let cookie = session_cookie(&state, did, None);
9475        let (ct, body) = opml_multipart(opml_with_feeds(10).as_bytes());
9476        let app = router(state.clone());
9477
9478        let resp = app
9479            .oneshot(
9480                Request::builder()
9481                    .method("POST")
9482                    .uri("/opml")
9483                    .header(header::COOKIE, cookie)
9484                    .header("content-type", ct)
9485                    .body(Body::from(body))
9486                    .unwrap(),
9487            )
9488            .await
9489            .unwrap();
9490        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9491
9492        let feeds = store::count_feeds(&state.db).await.unwrap();
9493        assert!(
9494            feeds <= 3,
9495            "OPML import blew past the global ceiling: {feeds} feeds cached with cap=3"
9496        );
9497    }
9498
9499    /// POST an OPML of `n` feeds as `did` against a strict fake PDS behind the
9500    /// sidecar, and return the flash it redirected with plus the fake's log.
9501    async fn import_against_strict_pds(
9502        did: &str,
9503        n: usize,
9504        fail_call: Option<usize>,
9505    ) -> (String, crate::atproto::tests::ApplyWritesLog) {
9506        let (sidecar, log) = crate::atproto::tests::serve_apply_writes(fail_call).await;
9507        let state = test_state_with_sidecar(&[did], &sidecar).await;
9508        let cookie = session_cookie(&state, did, None);
9509        let (ct, body) = opml_multipart(opml_with_feeds(n).as_bytes());
9510        let resp = router(state)
9511            .oneshot(
9512                Request::builder()
9513                    .method("POST")
9514                    .uri("/opml")
9515                    .header(header::COOKIE, cookie)
9516                    .header("content-type", ct)
9517                    .body(Body::from(body))
9518                    .unwrap(),
9519            )
9520            .await
9521            .unwrap();
9522        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9523        let loc = resp.headers()[header::LOCATION].to_str().unwrap();
9524        let flash = url::Url::parse(&format!("http://x{loc}"))
9525            .unwrap()
9526            .query_pairs()
9527            .find(|(k, _)| k == "flash")
9528            .map(|(_, v)| v.into_owned())
9529            .unwrap_or_default();
9530        (flash, log)
9531    }
9532
9533    /// **An OPML import of more than 200 feeds succeeds** against a PDS that
9534    /// refuses more than 200 writes a call, as the reference PDS does. It used
9535    /// to go out as one `applyWrites` and fail outright, importing nothing.
9536    #[tokio::test]
9537    async fn opml_import_of_450_feeds_succeeds_against_a_pds_capping_at_200() {
9538        let (flash, log) = import_against_strict_pds("did:plc:bigimport", 450, None).await;
9539        assert_eq!(flash, "Imported 450 feeds", "{flash}");
9540        assert_eq!(crate::atproto::tests::call_sizes(&log), vec![200, 200, 50]);
9541    }
9542
9543    /// **A part-landed import says so.** Chunk 2 of 3 fails: chunk 1's 200
9544    /// feeds are in the reader's repo, and "nothing was imported" — what the
9545    /// handler said for any failure — would be false.
9546    #[tokio::test]
9547    async fn opml_import_that_part_lands_reports_what_landed() {
9548        let (flash, log) = import_against_strict_pds("did:plc:partimport", 450, Some(2)).await;
9549        assert_eq!(crate::atproto::tests::call_sizes(&log), vec![200, 200]);
9550        assert!(
9551            flash.contains("200 of 450"),
9552            "the landed count is not reported: {flash}"
9553        );
9554        assert!(
9555            !flash.contains("nothing was imported"),
9556            "200 feeds landed and the reader was told none did: {flash}"
9557        );
9558    }
9559
9560    /// A batch that failed on its first call still reports that nothing was
9561    /// imported — true, since nothing after a failed call is sent.
9562    #[tokio::test]
9563    async fn opml_import_that_fails_on_the_first_call_imports_nothing() {
9564        let (flash, log) = import_against_strict_pds("did:plc:noimport", 450, Some(1)).await;
9565        assert_eq!(crate::atproto::tests::call_sizes(&log), vec![200]);
9566        assert!(flash.contains("nothing was imported"), "{flash}");
9567    }
9568
9569    /// **A malformed `at://` on the add path is "not a kind of feed we take",
9570    /// not "private/paid".** The first gate was the privacy classifier, whose
9571    /// at:// arm fails closed as `Private` for anything not a well-formed
9572    /// publication URI — so a typo (`did:plc:TOOSHORT`, a missing rkey) drew
9573    /// the private-feed flash and a "refused private/paid feed" log line. On
9574    /// main the same input reached `resolve_feed_url` and got "Couldn't find a
9575    /// feed". Storability is decided first for an at:// input, with its own
9576    /// message.
9577    #[tokio::test]
9578    async fn a_malformed_at_uri_on_the_add_path_is_refused_as_unsupported_not_private() {
9579        let did = "did:plc:typoist";
9580        let state = test_state_with_caps(did, 0, 0).await;
9581        let cookie = session_cookie(&state, did, None);
9582        for input in [
9583            "at%3A%2F%2Falice.example.com%2Fsite.standard.publication",
9584            "at%3A%2F%2Fdid%3Aplc%3ATOOSHORT%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
9585        ] {
9586            let resp = router(state.clone())
9587                .oneshot(
9588                    Request::builder()
9589                        .method("POST")
9590                        .uri("/subscriptions")
9591                        .header(header::COOKIE, cookie.clone())
9592                        .header("content-type", "application/x-www-form-urlencoded")
9593                        .body(Body::from(format!("url={input}")))
9594                        .unwrap(),
9595                )
9596                .await
9597                .unwrap();
9598            assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9599            let loc = resp
9600                .headers()
9601                .get(header::LOCATION)
9602                .unwrap()
9603                .to_str()
9604                .unwrap();
9605            assert!(
9606                loc.contains("kind%20of%20feed"),
9607                "expected the unsupported-feed flash for {input}, got {loc}"
9608            );
9609            assert!(
9610                !loc.contains("Private"),
9611                "a storability refusal was reported as a privacy one for {input}: {loc}"
9612            );
9613        }
9614        assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
9615    }
9616
9617    /// **An OPML entry this instance cannot store is counted and reported, not
9618    /// silently dropped.** The storability `continue` incremented nothing,
9619    /// while the privacy branch beside it produced a user-visible label — so
9620    /// an OPML exported from a standard.site-enabled instance imported
9621    /// "successfully" with entries missing and no reason given. The reader is
9622    /// told how many, and why.
9623    #[tokio::test]
9624    async fn opml_import_reports_entries_this_instance_cannot_store() {
9625        let did = "did:plc:renamer4";
9626        let (sidecar, _puts) = spawn_rename_sidecar(seeded_subscription()).await;
9627        let state = test_state_with_sidecar(&[did], &sidecar).await;
9628        assert!(!state.config.standard_site);
9629        let opml = format!(
9630            "<?xml version=\"1.0\"?>\n<opml version=\"2.0\"><head><title>t</title></head><body>\n\
9631             <outline type=\"rss\" text=\"Real\" xmlUrl=\"https://real.example/feed.xml\"/>\n\
9632             <outline type=\"rss\" text=\"Pub\" xmlUrl=\"{AT_URI_SUB}\"/>\n\
9633             </body></opml>"
9634        );
9635        let (ct, body) = opml_multipart(opml.as_bytes());
9636        let cookie = session_cookie(&state, did, None);
9637        let resp = router(state.clone())
9638            .oneshot(
9639                Request::builder()
9640                    .method("POST")
9641                    .uri("/opml")
9642                    .header(header::COOKIE, cookie)
9643                    .header("content-type", ct)
9644                    .body(Body::from(body))
9645                    .unwrap(),
9646            )
9647            .await
9648            .unwrap();
9649        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9650        let loc = resp
9651            .headers()
9652            .get(header::LOCATION)
9653            .unwrap()
9654            .to_str()
9655            .unwrap();
9656        assert!(
9657            loc.contains("Imported%201%20feed"),
9658            "unexpected flash: {loc}"
9659        );
9660        assert!(
9661            loc.contains("1%20feed%28s%29%20skipped") && loc.contains("can%20subscribe%20to"),
9662            "the dropped entry was not reported: {loc}"
9663        );
9664        // Reported by count only: the at-URI itself is not echoed back.
9665        assert!(
9666            !loc.contains("site.standard.publication"),
9667            "the URI was echoed: {loc}"
9668        );
9669    }
9670
9671    /// OPML bulk import must honour the PER-DID subscription cap: a DID at its
9672    /// cap imports zero new feeds.
9673    #[tokio::test]
9674    async fn opml_import_enforces_per_did_cap() {
9675        let did = "did:plc:capped";
9676        // Per-DID cap 2, global unlimited. Pre-seed the DID at its cap.
9677        let state = test_state_with_caps(did, 2, 0).await;
9678        let existing_a = store::upsert_feed(
9679            &state.db,
9680            &store::NewFeed {
9681                url: "https://have-a.example/feed.xml".to_string(),
9682                ..Default::default()
9683            },
9684        )
9685        .await
9686        .unwrap();
9687        let existing_b = store::upsert_feed(
9688            &state.db,
9689            &store::NewFeed {
9690                url: "https://have-b.example/feed.xml".to_string(),
9691                ..Default::default()
9692            },
9693        )
9694        .await
9695        .unwrap();
9696        store::replace_sub_refs(&state.db, did, &[existing_a, existing_b])
9697            .await
9698            .unwrap();
9699        let before = store::count_feeds(&state.db).await.unwrap();
9700
9701        let cookie = session_cookie(&state, did, None);
9702        let (ct, body) = opml_multipart(opml_with_feeds(10).as_bytes());
9703        let app = router(state.clone());
9704        let resp = app
9705            .oneshot(
9706                Request::builder()
9707                    .method("POST")
9708                    .uri("/opml")
9709                    .header(header::COOKIE, cookie)
9710                    .header("content-type", ct)
9711                    .body(Body::from(body))
9712                    .unwrap(),
9713            )
9714            .await
9715            .unwrap();
9716        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9717        // Headroom was 0 → no new feeds imported into the shared cache.
9718        let after = store::count_feeds(&state.db).await.unwrap();
9719        assert_eq!(after, before, "over-cap DID imported new feeds anyway");
9720    }
9721
9722    /// Single-add per-DID cap: a DID at its subscription cap is refused before
9723    /// any fetch, with the limit flash.
9724    #[tokio::test]
9725    async fn single_add_enforces_per_did_cap() {
9726        let did = "did:plc:subcapped";
9727        let state = test_state_with_caps(did, 1, 0).await;
9728        let f = store::upsert_feed(
9729            &state.db,
9730            &store::NewFeed {
9731                url: "https://have.example/feed.xml".to_string(),
9732                ..Default::default()
9733            },
9734        )
9735        .await
9736        .unwrap();
9737        store::replace_sub_refs(&state.db, did, &[f]).await.unwrap();
9738        let cookie = session_cookie(&state, did, None);
9739        let app = router(state.clone());
9740        let resp = app
9741            .oneshot(
9742                Request::builder()
9743                    .method("POST")
9744                    .uri("/subscriptions")
9745                    .header(header::COOKIE, cookie)
9746                    .header("content-type", "application/x-www-form-urlencoded")
9747                    .body(Body::from("url=https://another.example/feed.xml"))
9748                    .unwrap(),
9749            )
9750            .await
9751            .unwrap();
9752        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9753        let loc = resp
9754            .headers()
9755            .get(header::LOCATION)
9756            .unwrap()
9757            .to_str()
9758            .unwrap();
9759        assert!(
9760            loc.contains("Subscription%20limit%20reached"),
9761            "expected sub-limit flash, got {loc}"
9762        );
9763    }
9764
9765    /// `GET /` renders at most one page of rows and offers a way to the rest.
9766    ///
9767    /// The handler used to materialize EVERY unread entry — `SELECT e.*`, no
9768    /// `LIMIT`, article bodies included — and hand the lot to the template. With
9769    /// 250 entries that is the whole list in one response; with a real backlog on
9770    /// a 512 MB box it is the OOM the operator review flagged. Asserts the page
9771    /// is capped, the heading still reports the true total, and page 2 is
9772    /// reachable and disjoint.
9773    #[tokio::test]
9774    async fn the_reader_index_pages_instead_of_rendering_everything() {
9775        let did = "did:plc:pager";
9776        let state = test_state(&[]).await;
9777        store::grant_access(&state.db, did, None, "test", None)
9778            .await
9779            .unwrap();
9780        let feed = store::upsert_feed(
9781            &state.db,
9782            &store::NewFeed {
9783                url: "https://pager.example/feed.xml".to_string(),
9784                title: Some("Pager".to_string()),
9785                ..Default::default()
9786            },
9787        )
9788        .await
9789        .unwrap();
9790        let total = 250_usize;
9791        let entries: Vec<store::NewEntry> = (0..total)
9792            .map(|i| store::NewEntry {
9793                guid: format!("p-{i:04}"),
9794                url: Some(format!("https://pager.example/{i}")),
9795                title: Some(format!("Article {i:04}")),
9796                published: Some(format!("2026-07-{:02}T00:00:00Z", (i % 28) + 1)),
9797                content_html: Some("x".repeat(4_000)),
9798                ..Default::default()
9799            })
9800            .collect();
9801        store::insert_entries(&state.db, feed, &entries, 0)
9802            .await
9803            .unwrap();
9804        store::replace_sub_refs(&state.db, did, &[feed])
9805            .await
9806            .unwrap();
9807
9808        let cookie = session_cookie(&state, did, None);
9809        let app = router(state.clone());
9810        let get = |uri: &str| {
9811            let app = app.clone();
9812            let cookie = cookie.clone();
9813            let uri = uri.to_string();
9814            async move {
9815                let resp = app
9816                    .oneshot(
9817                        Request::builder()
9818                            .uri(uri)
9819                            .header(header::COOKIE, cookie)
9820                            .body(Body::empty())
9821                            .unwrap(),
9822                    )
9823                    .await
9824                    .unwrap();
9825                assert_eq!(resp.status(), StatusCode::OK);
9826                let bytes = axum::body::to_bytes(resp.into_body(), 8 * 1024 * 1024)
9827                    .await
9828                    .unwrap();
9829                String::from_utf8(bytes.to_vec()).unwrap()
9830            }
9831        };
9832
9833        let page1 = get("/").await;
9834        // One `<li class="entry…>` per rendered row. Counting "/entries/" would
9835        // over-count: each row carries several (the link plus the read/star
9836        // forms).
9837        let rows1 = page1.matches("<li class=\"entry").count();
9838        assert!(
9839            rows1 <= ENTRIES_PER_PAGE as usize,
9840            "page 1 rendered {rows1} entry links; the list is unbounded"
9841        );
9842        assert!(
9843            rows1 > 0,
9844            "page 1 rendered nothing at all: the page bound swallowed the list"
9845        );
9846        // The count is the TRUE total, not the page size — otherwise paging
9847        // would quietly relabel a 250-entry backlog as a 100-entry one.
9848        assert!(
9849            page1.contains("250 entries"),
9850            "heading must report the full total, not the page"
9851        );
9852        assert!(
9853            page1.contains("page=2"),
9854            "no way to reach the rest of the list: {}",
9855            &page1[..page1.len().min(400)]
9856        );
9857        // The body never belongs in a list response.
9858        assert!(
9859            !page1.contains(&"x".repeat(4_000)),
9860            "the list response carried an article body"
9861        );
9862
9863        let page2 = get("/?page=2").await;
9864        assert!(
9865            page2.matches("<li class=\"entry").count() > 0,
9866            "page 2 rendered no rows at all"
9867        );
9868        assert!(
9869            page2.contains("page=1") || page2.contains("Newer"),
9870            "page 2 offers no way back"
9871        );
9872        // Disjoint: an article on page 1 must not reappear on page 2.
9873        let first_title = (0..total)
9874            .map(|i| format!("Article {i:04}"))
9875            .find(|t| page1.contains(t))
9876            .expect("page 1 shows at least one titled article");
9877        assert!(
9878            !page2.contains(&first_title),
9879            "{first_title} appears on both pages"
9880        );
9881
9882        // A page past the end must not be a dead end. The empty state renders
9883        // instead of the pager, so an out-of-range page would leave a reader
9884        // with no link back — reachable by typing a number, and reachable
9885        // WITHOUT typing anything by paging to the end and then marking entries
9886        // read, which shrinks the list under the URL already in the address bar.
9887        let past_end = get("/?page=999").await;
9888        assert!(
9889            past_end.matches("<li class=\"entry").count() > 0,
9890            "an out-of-range page rendered nothing and offered no way back"
9891        );
9892        assert!(
9893            past_end.contains("page=2"),
9894            "the clamped page offers no pager"
9895        );
9896    }
9897
9898    /// Reader-view mark-read (a request tagged `X-FR-Reader: 1`) must return the
9899    /// out-of-band action-bar fragment with FRESHLY re-read state so a second
9900    /// keypress reverses the toggle: `hx-swap-oob="outerHTML"` is present, and
9901    /// the hidden `read` input + `aria-pressed` reflect the NEW state. The list
9902    /// view (no reader header) instead swaps the row. This guards the reader OOB
9903    /// toggle wiring, which had no test.
9904    #[tokio::test]
9905    async fn reader_mark_read_returns_oob_actionbar_with_flipped_state() {
9906        let did = "did:plc:reader";
9907        let state = test_state(&[]).await;
9908        store::grant_access(&state.db, did, None, "test", None)
9909            .await
9910            .unwrap();
9911        let feed = store::upsert_feed(
9912            &state.db,
9913            &store::NewFeed {
9914                url: "https://reader.example/feed.xml".to_string(),
9915                title: Some("Reader".to_string()),
9916                ..Default::default()
9917            },
9918        )
9919        .await
9920        .unwrap();
9921        store::insert_entries(
9922            &state.db,
9923            feed,
9924            &[store::NewEntry {
9925                guid: "r-1".to_string(),
9926                url: Some("https://reader.example/1".to_string()),
9927                title: Some("Article".to_string()),
9928                published: Some("2026-07-11T00:00:00Z".to_string()),
9929                content_html: Some("<p>body</p>".to_string()),
9930                ..Default::default()
9931            }],
9932            0,
9933        )
9934        .await
9935        .unwrap();
9936        store::replace_sub_refs(&state.db, did, &[feed])
9937            .await
9938            .unwrap();
9939        let entry_id = store::entries_for_feed(&state.db, did, feed).await.unwrap()[0].id;
9940
9941        let cookie = session_cookie(&state, did, None);
9942        let app = router(state.clone());
9943
9944        // Reader-tagged mark-read → OOB action-bar fragment, entry now READ.
9945        let resp = app
9946            .clone()
9947            .oneshot(
9948                Request::builder()
9949                    .method("POST")
9950                    .uri(format!("/entries/{entry_id}/read"))
9951                    .header(header::COOKIE, cookie.clone())
9952                    .header("HX-Request", "true")
9953                    .header("X-FR-Reader", "1")
9954                    .header("content-type", "application/x-www-form-urlencoded")
9955                    .body(Body::from("read=true"))
9956                    .unwrap(),
9957            )
9958            .await
9959            .unwrap();
9960        assert_eq!(resp.status(), StatusCode::OK);
9961        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
9962            .await
9963            .unwrap();
9964        let html = String::from_utf8(bytes.to_vec()).unwrap();
9965        assert!(
9966            html.contains("hx-swap-oob=\"outerHTML\""),
9967            "reader response must be an OOB swap: {html}"
9968        );
9969        assert!(
9970            html.contains(r#"id="entry-actionbar""#),
9971            "reader response must be the action-bar fragment: {html}"
9972        );
9973        // Now READ: the read button reflects it (aria-pressed=true) and the
9974        // hidden value flips to `false` so the next tap marks it UNREAD.
9975        assert!(
9976            html.contains(r#"aria-pressed="true""#),
9977            "read button must show pressed after marking read: {html}"
9978        );
9979        assert!(
9980            html.contains(r#"name="read" value="false""#),
9981            "hidden read value must flip to false so a second tap reverses: {html}"
9982        );
9983
9984        // A second reader mark-read (submitting the flipped `read=false`) marks
9985        // it UNREAD again — the toggle reverses.
9986        let resp2 = app
9987            .oneshot(
9988                Request::builder()
9989                    .method("POST")
9990                    .uri(format!("/entries/{entry_id}/read"))
9991                    .header(header::COOKIE, cookie)
9992                    .header("HX-Request", "true")
9993                    .header("X-FR-Reader", "1")
9994                    .header("content-type", "application/x-www-form-urlencoded")
9995                    .body(Body::from("read=false"))
9996                    .unwrap(),
9997            )
9998            .await
9999            .unwrap();
10000        assert_eq!(resp2.status(), StatusCode::OK);
10001        let bytes2 = axum::body::to_bytes(resp2.into_body(), 64 * 1024)
10002            .await
10003            .unwrap();
10004        let html2 = String::from_utf8(bytes2.to_vec()).unwrap();
10005        assert!(
10006            html2.contains(r#"aria-pressed="false""#),
10007            "read button must show un-pressed after reversing: {html2}"
10008        );
10009        assert!(
10010            html2.contains(r#"name="read" value="true""#),
10011            "hidden read value must flip back to true: {html2}"
10012        );
10013    }
10014
10015    /// The LIST view (no `X-FR-Reader` header) swaps the row, not the OOB
10016    /// action-bar — the counterpart to the reader-OOB test above.
10017    #[tokio::test]
10018    async fn list_mark_read_returns_row_not_oob_actionbar() {
10019        let did = "did:plc:listv";
10020        let state = test_state(&[]).await;
10021        store::grant_access(&state.db, did, None, "test", None)
10022            .await
10023            .unwrap();
10024        let feed = store::upsert_feed(
10025            &state.db,
10026            &store::NewFeed {
10027                url: "https://list.example/feed.xml".to_string(),
10028                title: Some("List".to_string()),
10029                ..Default::default()
10030            },
10031        )
10032        .await
10033        .unwrap();
10034        store::insert_entries(
10035            &state.db,
10036            feed,
10037            &[store::NewEntry {
10038                guid: "l-1".to_string(),
10039                url: Some("https://list.example/1".to_string()),
10040                title: Some("Article".to_string()),
10041                published: Some("2026-07-11T00:00:00Z".to_string()),
10042                ..Default::default()
10043            }],
10044            0,
10045        )
10046        .await
10047        .unwrap();
10048        store::replace_sub_refs(&state.db, did, &[feed])
10049            .await
10050            .unwrap();
10051        let entry_id = store::entries_for_feed(&state.db, did, feed).await.unwrap()[0].id;
10052
10053        let cookie = session_cookie(&state, did, None);
10054        let app = router(state.clone());
10055
10056        let resp = app
10057            .oneshot(
10058                Request::builder()
10059                    .method("POST")
10060                    .uri(format!("/entries/{entry_id}/read"))
10061                    .header(header::COOKIE, cookie)
10062                    .header("HX-Request", "true")
10063                    .header("content-type", "application/x-www-form-urlencoded")
10064                    .body(Body::from("read=true"))
10065                    .unwrap(),
10066            )
10067            .await
10068            .unwrap();
10069        assert_eq!(resp.status(), StatusCode::OK);
10070        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
10071            .await
10072            .unwrap();
10073        let html = String::from_utf8(bytes.to_vec()).unwrap();
10074        assert!(
10075            !html.contains("hx-swap-oob"),
10076            "list-view response must NOT be an OOB swap: {html}"
10077        );
10078        // **And it must actually BE the row.** The assertion above is satisfied
10079        // by an empty body, or by any response that simply omits the attribute —
10080        // so on its own it pins half a property and the name promises the other
10081        // half.
10082        assert!(
10083            html.contains(&format!("/entries/{entry_id}")),
10084            "the response is not the row for this entry: {html}",
10085        );
10086        assert!(
10087            html.contains("Article"),
10088            "the row rendered without its title: {html}",
10089        );
10090        // **The row comes back carrying read state. That is all this proves.**
10091        //
10092        // It does NOT prove the state was persisted: the handler renders
10093        // `Some(read)` from the form value, so making `mark_read` roll back
10094        // instead of commit fails 11 store tests and leaves this one green.
10095        //
10096        // It does not prove the OVERRIDE either, which an earlier version of
10097        // this comment claimed. Verified: changing the call site to
10098        // `build_entry_row(pool, &did, id, None)` — deleting the override
10099        // wholesale — keeps the whole suite green, because `mark_read` has
10100        // already persisted the same value two lines earlier, so reading it back
10101        // from the database produces an identical row.
10102        //
10103        // Distinguishing the two needs a case where the override and the stored
10104        // state DISAGREE, which this handler never produces: it writes the value
10105        // it then renders. Left as a known gap rather than described as covered.
10106        assert!(
10107            html.contains("is-read"),
10108            "the row came back without the read state it was just given: {html}",
10109        );
10110    }
10111
10112    // -----------------------------------------------------------------------
10113    // Rename parity (POST /subscriptions/{rkey}/rename)
10114    // -----------------------------------------------------------------------
10115
10116    /// **Autodiscovery cannot smuggle a non-http(s) URL into storage.**
10117    ///
10118    /// The add path gates the URL the user *typed*; the URL it *stores* is
10119    /// whatever `resolve_feed_url` returns, which for an HTML page is a
10120    /// publisher-controlled `<link rel="alternate">` href. Two layers stop
10121    /// that: `discover_feed` yields only http(s), and the add path re-checks
10122    /// storability on the resolved URL. This test pins the DISJUNCTION —
10123    /// each layer alone holds it, both removed fails it — driven through the
10124    /// real route against a real local server.
10125    ///
10126    /// **Why the fixture is `ftp://`, not `at://`.** This began as the
10127    /// at-URI bypass test from #164, and it was vacuous twice over. Handle
10128    /// form: once storage became DID-only the privacy classifier refused it
10129    /// at its own gate. DID form: `Url::parse` cannot read it (invalid port
10130    /// — the colons in the DID), so `discover_feed` drops it before either
10131    /// layer exists. An at:// link cannot come out of autodiscovery under
10132    /// ANY mutation of the layers, so no test through this route can pin
10133    /// them with one. `ftp://` reaches both. The at:// case is guaranteed by
10134    /// structure and pinned where it lives: `discover_skips_a_non_http_
10135    /// alternate` and the storability tests in `feed.rs`.
10136    #[tokio::test]
10137    async fn autodiscovery_cannot_smuggle_a_non_http_url_into_storage() {
10138        let did = "did:plc:autodiscovered";
10139        // Access granted, both caps disabled — the only gates left are the
10140        // two under test.
10141        let state = test_state_with_caps(did, 0, 0).await;
10142
10143        let page = r#"<!doctype html><html><head><title>Blog</title>
10144            <link rel="alternate" type="application/rss+xml" href="ftp://files.example/feed.xml">
10145            </head><body>hi</body></html>"#;
10146        let base = crate::net::tests::serve_body(page.as_bytes().to_vec()).await;
10147        let port: u16 = base
10148            .trim_end_matches('/')
10149            .rsplit(':')
10150            .next()
10151            .unwrap()
10152            .parse()
10153            .unwrap();
10154        crate::net::test_host_override(
10155            "autodiscover-ftp.test",
10156            std::net::SocketAddr::from(([127, 0, 0, 1], port)),
10157        );
10158
10159        let cookie = session_cookie(&state, did, None);
10160        let resp = router(state.clone())
10161            .oneshot(
10162                Request::builder()
10163                    .method("POST")
10164                    .uri("/subscriptions")
10165                    .header(header::COOKIE, cookie)
10166                    .header("content-type", "application/x-www-form-urlencoded")
10167                    .body(Body::from(format!(
10168                        "url=http://autodiscover-ftp.test:{port}/"
10169                    )))
10170                    .unwrap(),
10171            )
10172            .await
10173            .unwrap();
10174        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10175        let loc = resp
10176            .headers()
10177            .get(header::LOCATION)
10178            .unwrap()
10179            .to_str()
10180            .unwrap();
10181        assert_ne!(loc, "/login", "the test never reached the add path");
10182        assert_ne!(loc, "/", "the subscribe succeeded");
10183
10184        assert_eq!(
10185            store::count_feeds(&state.db).await.unwrap(),
10186            0,
10187            "a non-http(s) URL from autodiscovery was stored"
10188        );
10189        assert_eq!(
10190            store::count_subscriptions_for_did(&state.db, did)
10191                .await
10192                .unwrap(),
10193            0
10194        );
10195    }
10196
10197    /// A rename that points at a BRAND-NEW feed URL while the shared cache is at
10198    /// its global ceiling must be refused (capacity flash) and must NOT insert a
10199    /// new `feeds` row — parity with add_subscription's global-cap guard, so a
10200    /// rename loop can't inflate the shared cache past the cap.
10201    #[tokio::test]
10202    async fn rename_to_new_url_refused_at_global_feeds_cap() {
10203        let did = "did:plc:renamer4";
10204        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
10205        // Global cap 1; pre-fill it with one feed so headroom is 0.
10206        let state = test_state_with_sidecar_and(&[did], &sidecar, false, 1).await;
10207        store::upsert_feed(
10208            &state.db,
10209            &store::NewFeed {
10210                url: "https://existing.example/feed.xml".to_string(),
10211                ..Default::default()
10212            },
10213        )
10214        .await
10215        .unwrap();
10216        let before = store::count_feeds(&state.db).await.unwrap();
10217        assert_eq!(before, 1);
10218
10219        let cookie = session_cookie(&state, did, None);
10220        let resp = router(state.clone())
10221            .oneshot(
10222                Request::builder()
10223                    .method("POST")
10224                    .uri("/subscriptions/rk-keep/rename")
10225                    .header(header::COOKIE, cookie)
10226                    .header("content-type", "application/x-www-form-urlencoded")
10227                    // A URL not in the cache → would be a NEW feeds row.
10228                    .body(Body::from(
10229                        "url=https://brand-new.example/feed.xml&title=Renamed",
10230                    ))
10231                    .unwrap(),
10232            )
10233            .await
10234            .unwrap();
10235        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10236        let loc = resp
10237            .headers()
10238            .get(header::LOCATION)
10239            .unwrap()
10240            .to_str()
10241            .unwrap();
10242        assert!(
10243            loc.contains("feed%20capacity"),
10244            "expected the feed-capacity flash, got {loc}"
10245        );
10246        // No new feeds row was inserted, and nothing reached the PDS.
10247        assert_eq!(store::count_feeds(&state.db).await.unwrap(), before);
10248        assert!(
10249            puts.lock().unwrap().is_empty(),
10250            "a refused repoint reached the PDS"
10251        );
10252    }
10253
10254    /// A repoint to an EXISTING URL adds no row, so it is allowed even at the
10255    /// global cap (only new URLs are gated) — the other half of the guard.
10256    ///
10257    /// On the sidecar fake, so "allowed" means the put actually happened: the
10258    /// earlier harness had no sidecar, and this passed on a "could not reach
10259    /// your PDS" flash that merely was not the capacity one.
10260    #[tokio::test]
10261    async fn rename_to_existing_url_allowed_at_global_feeds_cap() {
10262        let did = "did:plc:renamer4";
10263        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
10264        let state = test_state_with_sidecar_and(&[did], &sidecar, false, 1).await;
10265        store::upsert_feed(
10266            &state.db,
10267            &store::NewFeed {
10268                url: "https://existing.example/feed.xml".to_string(),
10269                ..Default::default()
10270            },
10271        )
10272        .await
10273        .unwrap();
10274        let before = store::count_feeds(&state.db).await.unwrap();
10275
10276        let cookie = session_cookie(&state, did, None);
10277        let resp = router(state.clone())
10278            .oneshot(
10279                Request::builder()
10280                    .method("POST")
10281                    .uri("/subscriptions/rk-keep/rename")
10282                    .header(header::COOKIE, cookie)
10283                    .header("content-type", "application/x-www-form-urlencoded")
10284                    .body(Body::from(
10285                        "url=https://existing.example/feed.xml&title=Retitled",
10286                    ))
10287                    .unwrap(),
10288            )
10289            .await
10290            .unwrap();
10291        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10292        let loc = resp
10293            .headers()
10294            .get(header::LOCATION)
10295            .unwrap()
10296            .to_str()
10297            .unwrap();
10298        assert_eq!(loc, "/", "the repoint to a cached URL was refused: {loc}");
10299        assert_eq!(
10300            puts.lock().unwrap().len(),
10301            1,
10302            "the repoint did not reach the PDS"
10303        );
10304        assert_eq!(store::count_feeds(&state.db).await.unwrap(), before);
10305    }
10306
10307    /// A rename with a blank URL writes nothing anywhere.
10308    #[tokio::test]
10309    async fn rename_with_blank_url_writes_nothing() {
10310        let did = "did:plc:renamer3";
10311        let state = test_state_with_caps(did, 0, 0).await;
10312        let before = store::count_feeds(&state.db).await.unwrap();
10313        assert_eq!(before, 0);
10314
10315        let cookie = session_cookie(&state, did, None);
10316        let app = router(state.clone());
10317        let resp = app
10318            .oneshot(
10319                Request::builder()
10320                    .method("POST")
10321                    .uri("/subscriptions/rkey123/rename")
10322                    .header(header::COOKIE, cookie)
10323                    .header("content-type", "application/x-www-form-urlencoded")
10324                    // Whitespace-only URL trims to empty.
10325                    .body(Body::from("url=%20%20&title=Nope"))
10326                    .unwrap(),
10327            )
10328            .await
10329            .unwrap();
10330        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10331        assert_eq!(
10332            resp.headers()
10333                .get(header::LOCATION)
10334                .unwrap()
10335                .to_str()
10336                .unwrap(),
10337            "/",
10338        );
10339        // Nothing was cached.
10340        assert_eq!(
10341            store::count_feeds(&state.db).await.unwrap(),
10342            0,
10343            "blank-URL rename wrote a junk feeds row"
10344        );
10345    }
10346
10347    /// A sidecar mock that serves ONE existing subscription record and captures
10348    /// every `put` body a rename produces.
10349    ///
10350    /// **Reads to `content-length` rather than taking one `read`.** A single
10351    /// read gets whatever one segment carried; if the head and body land
10352    /// separately the capture holds no record and every field assertion below
10353    /// passes for the wrong reason. Each captured body must also mention the
10354    /// collection, so an empty capture fails loudly instead of quietly.
10355    async fn spawn_rename_sidecar(
10356        existing: serde_json::Value,
10357    ) -> (String, std::sync::Arc<std::sync::Mutex<Vec<String>>>) {
10358        use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
10359        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
10360        let addr = listener.local_addr().unwrap();
10361        let puts = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
10362        let sink = puts.clone();
10363        tokio::spawn(async move {
10364            loop {
10365                let Ok((mut sock, _)) = listener.accept().await else {
10366                    break;
10367                };
10368                let mut raw: Vec<u8> = Vec::new();
10369                let mut chunk = [0u8; 4096];
10370                let body_text = loop {
10371                    let Ok(n) = sock.read(&mut chunk).await else {
10372                        break String::new();
10373                    };
10374                    if n == 0 {
10375                        break String::from_utf8_lossy(&raw).to_string();
10376                    }
10377                    raw.extend_from_slice(&chunk[..n]);
10378                    let Some(split) = raw.windows(4).position(|w| w == b"\r\n\r\n") else {
10379                        continue;
10380                    };
10381                    let (head, body) = raw.split_at(split + 4);
10382                    let want = String::from_utf8_lossy(head).lines().find_map(|l| {
10383                        let (k, v) = l.split_once(':')?;
10384                        k.eq_ignore_ascii_case("content-length")
10385                            .then(|| v.trim().parse::<usize>().ok())?
10386                    });
10387                    if want.is_none_or(|want| body.len() >= want) {
10388                        break String::from_utf8_lossy(body).to_string();
10389                    }
10390                };
10391
10392                // `"action":"put"` is the rename write; anything else is the read.
10393                let is_put = body_text.contains("\"action\":\"put\"");
10394                let data = if is_put {
10395                    sink.lock().unwrap().push(body_text.clone());
10396                    serde_json::json!({
10397                        "uri": "at://did:plc:x/community.lexicon.rss.subscription/rk-keep",
10398                        "cid": "bafyreiafter"
10399                    })
10400                } else {
10401                    serde_json::json!({ "records": [existing.clone()] })
10402                };
10403                let body = serde_json::json!({ "ok": true, "data": data }).to_string();
10404                let resp = format!(
10405                    "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
10406                    body.len(),
10407                    body
10408                );
10409                let _ = sock.write_all(resp.as_bytes()).await;
10410                let _ = sock.flush().await;
10411            }
10412        });
10413        (format!("http://{addr}"), puts)
10414    }
10415
10416    /// The existing record a rename must not destroy.
10417    fn seeded_subscription() -> serde_json::Value {
10418        serde_json::json!({
10419            "uri": "at://did:plc:renamer4/community.lexicon.rss.subscription/rk-keep",
10420            "cid": "bafyreibefore",
10421            "value": {
10422                "$type": "community.lexicon.rss.subscription",
10423                "url": "https://example.com/feed.xml",
10424                "title": "Old title",
10425                "siteUrl": "https://example.com/blog",
10426                "fetchHint": "hourly",
10427                "private": false,
10428                "createdAt": "2024-03-01T00:00:00.000Z"
10429            }
10430        })
10431    }
10432
10433    /// An existing standard.site subscription, as the 19 in production are:
10434    /// written before this reader refused the scheme, still in the repo.
10435    fn seeded_at_uri_subscription() -> serde_json::Value {
10436        seeded_subscription_with_url(AT_URI_SUB)
10437    }
10438    /// An existing subscription record at `rk-keep` with the given URL.
10439    fn seeded_subscription_with_url(url: &str) -> serde_json::Value {
10440        serde_json::json!({
10441            "uri": "at://did:plc:renamer5/community.lexicon.rss.subscription/rk-keep",
10442            "cid": "bafyreibefore",
10443            "value": {
10444                "$type": "community.lexicon.rss.subscription",
10445                "url": url,
10446                "title": "Old title",
10447                "private": false,
10448                "createdAt": "2024-03-01T00:00:00.000Z"
10449            }
10450        })
10451    }
10452    const AT_URI_SUB: &str =
10453        "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lab2c4d5e6f7g8h";
10454    const AT_URI_SUB_ENC: &str =
10455        "at%3A%2F%2Fdid%3Aplc%3Aohutz6x5acjmpuulp3x7wxxc%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h";
10456
10457    /// **Retitling an existing `at://` subscription must work with the flag off.**
10458    ///
10459    /// The storability guard was placed before the repo lookup, so it refused
10460    /// any rename whose URL is an at-URI — including a pure title or folder
10461    /// change on a record that already exists. On main that rename succeeded;
10462    /// the 19 production records would have become un-editable. The flag gates
10463    /// what may be STORED in the cache, not whether a reader may edit their own
10464    /// record: the PDS write goes through, the cache row is simply not created.
10465    #[tokio::test]
10466    async fn retitling_an_existing_at_uri_subscription_survives_the_flag_being_off() {
10467        let did = "did:plc:renamer5";
10468        let (sidecar, puts) = spawn_rename_sidecar(seeded_at_uri_subscription()).await;
10469        let state = test_state_with_sidecar(&[did], &sidecar).await;
10470        assert!(
10471            !state.config.standard_site,
10472            "the flag must be off for this test"
10473        );
10474        let cookie = session_cookie(&state, did, None);
10475        let resp = router(state.clone())
10476            .oneshot(
10477                Request::builder()
10478                    .method("POST")
10479                    .uri("/subscriptions/rk-keep/rename")
10480                    .header(header::COOKIE, cookie)
10481                    .header("content-type", "application/x-www-form-urlencoded")
10482                    .body(Body::from(format!("url={AT_URI_SUB_ENC}&title=New+title")))
10483                    .unwrap(),
10484            )
10485            .await
10486            .unwrap();
10487        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10488        let loc = resp
10489            .headers()
10490            .get(header::LOCATION)
10491            .unwrap()
10492            .to_str()
10493            .unwrap();
10494        assert_eq!(loc, "/", "the retitle was refused: {loc}");
10495
10496        let bodies = puts.lock().unwrap().clone();
10497        assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
10498        let sent: serde_json::Value = serde_json::from_str(&bodies[0]).expect("put body is JSON");
10499        assert_eq!(
10500            sent["record"]["title"], "New title",
10501            "the rename did not apply"
10502        );
10503        assert_eq!(
10504            sent["record"]["url"], AT_URI_SUB,
10505            "the rename changed the URL"
10506        );
10507
10508        // The flag still means what it says for the CACHE: no at:// row.
10509        let cached: i64 = store::count_unpollable_feeds(&state.db).await.unwrap();
10510        assert_eq!(cached, 0, "a retitle stored an at:// row with the flag off");
10511    }
10512
10513    /// **Repointing a subscription AT an `at://` URI is still refused with the
10514    /// flag off** — the half of the guard that has to survive the fix above.
10515    /// Nothing reaches the PDS and nothing reaches the cache.
10516    #[tokio::test]
10517    async fn repointing_a_subscription_at_an_at_uri_is_refused_with_the_flag_off() {
10518        let did = "did:plc:renamer4";
10519        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
10520        let state = test_state_with_sidecar(&[did], &sidecar).await;
10521        let cookie = session_cookie(&state, did, None);
10522        let resp = router(state.clone())
10523            .oneshot(
10524                Request::builder()
10525                    .method("POST")
10526                    .uri("/subscriptions/rk-keep/rename")
10527                    .header(header::COOKIE, cookie)
10528                    .header("content-type", "application/x-www-form-urlencoded")
10529                    .body(Body::from(format!("url={AT_URI_SUB_ENC}&title=Moved")))
10530                    .unwrap(),
10531            )
10532            .await
10533            .unwrap();
10534        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10535        let loc = resp
10536            .headers()
10537            .get(header::LOCATION)
10538            .unwrap()
10539            .to_str()
10540            .unwrap();
10541        assert!(loc.contains("flash="), "the repoint was not refused: {loc}");
10542        assert!(
10543            !loc.contains("Private"),
10544            "a storability refusal was reported as a privacy one: {loc}"
10545        );
10546        assert!(
10547            puts.lock().unwrap().is_empty(),
10548            "the repoint reached the PDS"
10549        );
10550        let cached: i64 = store::count_unpollable_feeds(&state.db).await.unwrap();
10551        assert_eq!(cached, 0);
10552    }
10553
10554    /// Posts a retitle of `rk-keep` with its URL unchanged; returns the
10555    /// redirect location.
10556    async fn retitle_unchanged(state: &AppState, did: &str, url_enc: &str) -> String {
10557        let cookie = session_cookie(state, did, None);
10558        let resp = router(state.clone())
10559            .oneshot(
10560                Request::builder()
10561                    .method("POST")
10562                    .uri("/subscriptions/rk-keep/rename")
10563                    .header(header::COOKIE, cookie)
10564                    .header("content-type", "application/x-www-form-urlencoded")
10565                    .body(Body::from(format!("url={url_enc}&title=New+title")))
10566                    .unwrap(),
10567            )
10568            .await
10569            .unwrap();
10570        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10571        resp.headers()
10572            .get(header::LOCATION)
10573            .unwrap()
10574            .to_str()
10575            .unwrap()
10576            .to_string()
10577    }
10578
10579    /// **The privacy gate has the same ordering bug the storable gate had.**
10580    ///
10581    /// Another client can write a subscription whose URL is an at-URI that is
10582    /// not a well-formed publication URI at all — a feed generator, say. On
10583    /// main a retitle of it succeeded (the DID form fails `Url::parse`, which
10584    /// the classifier reads as `Public`). The narrowed at:// arm now fails
10585    /// closed as `Private` for it, and the gate ran before `url_changed` was
10586    /// known — so the record became un-editable, with a flash claiming it "was
10587    /// not saved or sent anywhere". Both gates now apply to a repoint only.
10588    #[tokio::test]
10589    async fn retitling_an_existing_at_uri_record_that_is_not_a_publication_survives() {
10590        let did = "did:plc:renamer5";
10591        let other = "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/app.bsky.feed.generator/whats-hot";
10592        let other_enc =
10593            "at%3A%2F%2Fdid%3Aplc%3Aohutz6x5acjmpuulp3x7wxxc%2Fapp.bsky.feed.generator%2Fwhats-hot";
10594        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription_with_url(other)).await;
10595        let state = test_state_with_sidecar(&[did], &sidecar).await;
10596        let loc = retitle_unchanged(&state, did, other_enc).await;
10597        assert_eq!(loc, "/", "the retitle was refused: {loc}");
10598        let bodies = puts.lock().unwrap().clone();
10599        assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
10600        let sent: serde_json::Value = serde_json::from_str(&bodies[0]).unwrap();
10601        assert_eq!(sent["record"]["title"], "New title");
10602        assert_eq!(sent["record"]["url"], other);
10603    }
10604
10605    /// **A repoint to a secret-bearing URL is still refused** — the half of
10606    /// the privacy gate that has to survive moving it behind `url_changed`.
10607    /// Found by mutation: with the gate deleted outright, nothing failed.
10608    #[tokio::test]
10609    async fn repointing_a_subscription_at_a_private_feed_is_refused() {
10610        let did = "did:plc:renamer4";
10611        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
10612        let state = test_state_with_sidecar(&[did], &sidecar).await;
10613        let cookie = session_cookie(&state, did, None);
10614        let resp = router(state.clone())
10615            .oneshot(
10616                Request::builder()
10617                    .method("POST")
10618                    .uri("/subscriptions/rk-keep/rename")
10619                    .header(header::COOKIE, cookie)
10620                    .header("content-type", "application/x-www-form-urlencoded")
10621                    .body(Body::from(
10622                        "url=https%3A%2F%2Fpaid.example%2Ffeed.xml%3Ftoken%3DZm9vYmFyc2VjcmV0dG9rZW4&title=Moved",
10623                    ))
10624                    .unwrap(),
10625            )
10626            .await
10627            .unwrap();
10628        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10629        let loc = resp
10630            .headers()
10631            .get(header::LOCATION)
10632            .unwrap()
10633            .to_str()
10634            .unwrap();
10635        assert!(
10636            loc.contains("Private"),
10637            "the private repoint was not refused: {loc}"
10638        );
10639        assert!(
10640            puts.lock().unwrap().is_empty(),
10641            "a secret-bearing URL reached the PDS"
10642        );
10643        // The repo's fixture token: opaque enough for the classifier, not a real
10644        // key shape (a Stripe-shaped fixture tripped the secret scanner — rightly).
10645        let leaked = "https://paid.example/feed.xml?token=Zm9vYmFyc2VjcmV0dG9rZW4";
10646        assert!(store::get_feed_by_url(&state.db, leaked)
10647            .await
10648            .unwrap()
10649            .is_none());
10650    }
10651
10652    /// **A retitle of a never-cached at:// subscription is not "at feed
10653    /// capacity".** The global-ceiling check keyed on "URL not in the cache",
10654    /// and an at:// record is never cached with the flag off — so at capacity,
10655    /// a pure retitle was refused for a row the handler would not insert. The
10656    /// check now runs once `url_changed` is known and only for a repoint.
10657    #[tokio::test]
10658    async fn retitling_an_uncached_at_uri_subscription_is_not_refused_at_feed_capacity() {
10659        let did = "did:plc:renamer5";
10660        let (sidecar, puts) = spawn_rename_sidecar(seeded_at_uri_subscription()).await;
10661        // Ceiling 1, and one real feed already fills it.
10662        let state = test_state_with_sidecar_and(&[did], &sidecar, false, 1).await;
10663        store::upsert_feed(
10664            &state.db,
10665            &store::NewFeed {
10666                url: "https://filler.example/feed.xml".to_string(),
10667                ..Default::default()
10668            },
10669        )
10670        .await
10671        .unwrap();
10672        let loc = retitle_unchanged(&state, did, AT_URI_SUB_ENC).await;
10673        assert_eq!(loc, "/", "the retitle was refused: {loc}");
10674        assert_eq!(
10675            puts.lock().unwrap().len(),
10676            1,
10677            "the retitle did not reach the PDS"
10678        );
10679        assert_eq!(
10680            store::count_feeds(&state.db).await.unwrap(),
10681            1,
10682            "a row was inserted"
10683        );
10684    }
10685
10686    /// POST `/subscriptions` with `url`, returning the redirect target.
10687    async fn subscribe(state: &AppState, did: &str, url_enc: &str) -> String {
10688        let cookie = session_cookie(state, did, None);
10689        let resp = router(state.clone())
10690            .oneshot(
10691                Request::builder()
10692                    .method("POST")
10693                    .uri("/subscriptions")
10694                    .header(header::COOKIE, cookie)
10695                    .header("content-type", "application/x-www-form-urlencoded")
10696                    .body(Body::from(format!("url={url_enc}")))
10697                    .unwrap(),
10698            )
10699            .await
10700            .unwrap();
10701        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10702        resp.headers()
10703            .get(header::LOCATION)
10704            .unwrap()
10705            .to_str()
10706            .unwrap()
10707            .to_string()
10708    }
10709
10710    /// A `com.atproto.identity.resolveHandle` that answers `did` for anything.
10711    async fn serve_resolver(did: &str) -> String {
10712        let base = crate::net::tests::serve_body(
10713            serde_json::json!({ "did": did }).to_string().into_bytes(),
10714        )
10715        .await;
10716        let port: u16 = base
10717            .trim_end_matches('/')
10718            .rsplit(':')
10719            .next()
10720            .unwrap()
10721            .parse()
10722            .unwrap();
10723        let host = format!("resolver-{port}.test");
10724        crate::net::test_host_override(&host, std::net::SocketAddr::from(([127, 0, 0, 1], port)));
10725        format!("http://{host}:{port}")
10726    }
10727
10728    fn with_config(mut state: AppState, f: impl FnOnce(&mut Config)) -> AppState {
10729        let mut config = (*state.config).clone();
10730        f(&mut config);
10731        state.config = std::sync::Arc::new(config);
10732        state
10733    }
10734
10735    /// **0.4.0 step 3: with the flag ON, a well-formed at:// paste is
10736    /// subscribed.** It was refused as unsupported while nothing could read a
10737    /// publication; the poller reads them now. Stored in DID form, as a
10738    /// `publication`, and written to the reader's PDS like any subscription.
10739    #[tokio::test]
10740    async fn a_well_formed_at_uri_paste_is_subscribed_with_the_flag_on() {
10741        let did = "did:plc:renamer5";
10742        let (sidecar, log) = spawn_logging_sidecar().await;
10743        let state = with_config(
10744            test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
10745            |c| {
10746                c.oauth.plc_directory = "http://plc.nowhere.invalid".into();
10747            },
10748        );
10749        let loc = subscribe(&state, did, AT_URI_SUB_ENC).await;
10750        assert_eq!(loc, "/", "the paste was refused: {loc}");
10751        let row = store::get_feed_by_url(&state.db, AT_URI_SUB)
10752            .await
10753            .unwrap()
10754            .expect("no feed row");
10755        assert_eq!(feed::FeedKind::of(&row.url), feed::FeedKind::Publication);
10756        let sent = log.lock().unwrap().join("\n");
10757        assert!(
10758            sent.contains(AT_URI_SUB),
10759            "the subscription was not written to the PDS: {sent}"
10760        );
10761    }
10762
10763    /// **A0 through the form** — the 0.4.0 exit test, end to end: a reader
10764    /// pastes a publication, it is stored and written to their PDS, and the
10765    /// first poll — the one subscribing runs at once — stores its documents.
10766    #[tokio::test]
10767    async fn a0_subscribing_from_the_form_delivers_entries() {
10768        let did = "did:plc:renamer5";
10769        let author = "did:plc:ohutz6x5acjmpuulp3x7wxxc";
10770        let site = AT_URI_SUB;
10771        let (plc, _) = crate::standard_site::tests::serve_repo(
10772            author,
10773            vec![
10774                (
10775                    lexicon::nsid::STANDARD_PUBLICATION,
10776                    "3lab2c4d5e6f7g8h",
10777                    serde_json::json!({ "name": "A0 Journal", "url": "https://a0.example" }),
10778                ),
10779                (
10780                    lexicon::nsid::STANDARD_DOCUMENT,
10781                    "3l2a0frmaaa2a",
10782                    serde_json::json!({ "title": "From the form", "path": "/f",
10783                        "publishedAt": "2026-07-11T00:00:00Z", "site": site }),
10784                ),
10785            ],
10786        )
10787        .await;
10788        let (sidecar, _log) = spawn_logging_sidecar().await;
10789        let state = with_config(
10790            test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
10791            |c| {
10792                c.oauth.plc_directory = plc;
10793            },
10794        );
10795        assert_eq!(subscribe(&state, did, AT_URI_SUB_ENC).await, "/");
10796        let row = store::get_feed_by_url(&state.db, site)
10797            .await
10798            .unwrap()
10799            .unwrap();
10800        let titles: Vec<String> = sqlx::query_scalar("SELECT title FROM entries WHERE feed_id = ?")
10801            .bind(row.id)
10802            .fetch_all(&state.db)
10803            .await
10804            .unwrap();
10805        assert_eq!(
10806            titles,
10807            vec!["From the form".to_string()],
10808            "the first poll stored nothing"
10809        );
10810        assert_eq!(row.title.as_deref(), Some("A0 Journal"));
10811    }
10812
10813    /// A handle-form paste is resolved to the DID before it is stored: a
10814    /// handle is a mutable name, and `feeds.url` is keyed on identity.
10815    #[tokio::test]
10816    async fn a_handle_form_paste_is_stored_by_its_did() {
10817        let did = "did:plc:renamer5";
10818        let author = "did:plc:ohutz6x5acjmpuulp3x7wxxc";
10819        let (sidecar, _log) = spawn_logging_sidecar().await;
10820        let resolver = serve_resolver(author).await;
10821        let state = with_config(
10822            test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
10823            |c| {
10824                c.resolver_base = resolver;
10825                c.oauth.plc_directory = "http://plc.nowhere.invalid".into();
10826            },
10827        );
10828        let loc = subscribe(
10829            &state,
10830            did,
10831            "at%3A%2F%2Falice.example.com%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
10832        )
10833        .await;
10834        assert_eq!(loc, "/", "the paste was refused: {loc}");
10835        assert!(
10836            store::get_feed_by_url(&state.db, AT_URI_SUB)
10837                .await
10838                .unwrap()
10839                .is_some(),
10840            "not stored by its DID"
10841        );
10842        assert_eq!(
10843            store::count_feeds(&state.db).await.unwrap(),
10844            1,
10845            "the handle form was stored too"
10846        );
10847    }
10848
10849    /// A resolver answering `did` that counts how often it was asked.
10850    async fn serve_counting_resolver(
10851        did: &str,
10852    ) -> (String, std::sync::Arc<std::sync::atomic::AtomicUsize>) {
10853        let (base, hits) = crate::net::tests::serve_body_counted(
10854            serde_json::json!({ "did": did }).to_string().into_bytes(),
10855        )
10856        .await;
10857        let port: u16 = base
10858            .trim_end_matches('/')
10859            .rsplit(':')
10860            .next()
10861            .unwrap()
10862            .parse()
10863            .unwrap();
10864        let host = format!("counting-resolver-{port}.test");
10865        crate::net::test_host_override(&host, std::net::SocketAddr::from(([127, 0, 0, 1], port)));
10866        (format!("http://{host}:{port}"), hits)
10867    }
10868
10869    /// Review of #230: the per-DID cap is documented as checked "BEFORE any
10870    /// fetch/resolve so an over-cap account can't even trigger an outbound
10871    /// request" — a handle paste resolved the handle first.
10872    #[tokio::test]
10873    async fn an_over_cap_handle_paste_makes_no_outbound_request() {
10874        let did = "did:plc:renamer5";
10875        let (sidecar, _log) = spawn_logging_sidecar().await;
10876        let (resolver, hits) = serve_counting_resolver("did:plc:ohutz6x5acjmpuulp3x7wxxc").await;
10877        let state = with_config(
10878            test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
10879            |c| {
10880                c.resolver_base = resolver;
10881                c.max_subs_per_did = 1;
10882            },
10883        );
10884        let feed_id = store::upsert_feed(
10885            &state.db,
10886            &store::NewFeed {
10887                url: "https://already.example/feed.xml".into(),
10888                ..Default::default()
10889            },
10890        )
10891        .await
10892        .unwrap();
10893        store::replace_sub_refs(&state.db, did, &[feed_id])
10894            .await
10895            .unwrap();
10896        let loc = subscribe(
10897            &state,
10898            did,
10899            "at%3A%2F%2Falice.example.com%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
10900        )
10901        .await;
10902        assert!(
10903            loc.contains("Subscription%20limit"),
10904            "expected the cap flash: {loc}"
10905        );
10906        assert_eq!(
10907            hits.load(std::sync::atomic::Ordering::SeqCst),
10908            0,
10909            "an over-cap paste resolved a handle"
10910        );
10911    }
10912
10913    /// Review of #230: an authority that is neither a valid DID nor a valid
10914    /// handle — `did:plc:TOOSHORT`, an uppercase DID — went to the resolver as
10915    /// a "handle". It is unsupported, and asks nobody anything.
10916    #[tokio::test]
10917    async fn a_malformed_did_paste_is_unsupported_with_the_flag_on() {
10918        let did = "did:plc:renamer5";
10919        let (sidecar, _log) = spawn_logging_sidecar().await;
10920        let (resolver, hits) = serve_counting_resolver("did:plc:ohutz6x5acjmpuulp3x7wxxc").await;
10921        let state = with_config(
10922            test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
10923            |c| {
10924                c.resolver_base = resolver;
10925            },
10926        );
10927        for authority in [
10928            "did%3Aplc%3ATOOSHORT",
10929            "did%3Aplc%3AOHUTZ6X5ACJMPUULP3X7WXXC",
10930            "bad%0Ahandle.example",
10931        ] {
10932            let loc = subscribe(
10933                &state,
10934                did,
10935                &format!("at%3A%2F%2F{authority}%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h"),
10936            )
10937            .await;
10938            assert!(
10939                loc.contains("kind%20of%20feed"),
10940                "{authority}: expected the unsupported flash: {loc}"
10941            );
10942        }
10943        assert_eq!(
10944            hits.load(std::sync::atomic::Ordering::SeqCst),
10945            0,
10946            "a malformed authority reached the resolver"
10947        );
10948        assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
10949    }
10950
10951    /// A handle that does not resolve is refused, and nothing is stored.
10952    #[tokio::test]
10953    async fn an_unresolvable_handle_paste_is_refused() {
10954        let did = "did:plc:renamer5";
10955        let (sidecar, _log) = spawn_logging_sidecar().await;
10956        let state = with_config(
10957            test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
10958            |c| {
10959                c.resolver_base = "http://resolver.nowhere.invalid".into();
10960            },
10961        );
10962        let loc = subscribe(
10963            &state,
10964            did,
10965            "at%3A%2F%2Fnobody.example.com%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
10966        )
10967        .await;
10968        assert!(
10969            loc.contains("resolve%20the%20handle"),
10970            "expected the unresolvable-handle flash: {loc}"
10971        );
10972        assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
10973    }
10974
10975    /// An at:// URI that is not a publication is refused, flag on or off.
10976    #[tokio::test]
10977    async fn a_non_publication_at_uri_paste_is_refused() {
10978        let did = "did:plc:renamer5";
10979        let (sidecar, _log) = spawn_logging_sidecar().await;
10980        let state = test_state_with_sidecar_and(&[did], &sidecar, true, 0).await;
10981        let loc = subscribe(
10982            &state,
10983            did,
10984            "at%3A%2F%2Fdid%3Aplc%3Aohutz6x5acjmpuulp3x7wxxc%2Fapp.bsky.feed.post%2F3lab2c4d5e6f7g8h",
10985        )
10986        .await;
10987        assert!(
10988            loc.contains("kind%20of%20feed"),
10989            "expected the unsupported flash: {loc}"
10990        );
10991        assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
10992    }
10993
10994    /// A mixed-case scheme is canonicalised at input, not refused and not
10995    /// stored as a second spelling of the same publication.
10996    #[tokio::test]
10997    async fn a_mixed_case_at_scheme_paste_is_stored_canonically() {
10998        let did = "did:plc:renamer5";
10999        let (sidecar, _log) = spawn_logging_sidecar().await;
11000        let state = with_config(
11001            test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
11002            |c| {
11003                c.oauth.plc_directory = "http://plc.nowhere.invalid".into();
11004            },
11005        );
11006        let loc = subscribe(&state, did, &AT_URI_SUB_ENC.replacen("at", "At", 1)).await;
11007        assert_eq!(loc, "/", "the paste was refused: {loc}");
11008        assert!(store::get_feed_by_url(&state.db, AT_URI_SUB)
11009            .await
11010            .unwrap()
11011            .is_some());
11012    }
11013
11014    /// **With the flag ON, an OPML at:// entry is stored.** The one storage
11015    /// path that is meant to work today, asserted with the flag actually on.
11016    #[tokio::test]
11017    async fn opml_import_stores_an_at_uri_entry_with_the_flag_on() {
11018        let did = "did:plc:renamer5";
11019        let (sidecar, _puts) = spawn_rename_sidecar(seeded_subscription()).await;
11020        let state = test_state_with_sidecar_and(&[did], &sidecar, true, 0).await;
11021        let opml = format!(
11022            "<?xml version=\"1.0\"?>\n<opml version=\"2.0\"><head><title>t</title></head><body>\n\
11023             <outline type=\"rss\" text=\"Real\" xmlUrl=\"https://real.example/feed.xml\"/>\n\
11024             <outline type=\"rss\" text=\"Pub\" xmlUrl=\"{AT_URI_SUB}\"/>\n\
11025             </body></opml>"
11026        );
11027        let (ct, body) = opml_multipart(opml.as_bytes());
11028        let cookie = session_cookie(&state, did, None);
11029        let resp = router(state.clone())
11030            .oneshot(
11031                Request::builder()
11032                    .method("POST")
11033                    .uri("/opml")
11034                    .header(header::COOKIE, cookie)
11035                    .header("content-type", ct)
11036                    .body(Body::from(body))
11037                    .unwrap(),
11038            )
11039            .await
11040            .unwrap();
11041        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11042        let loc = resp
11043            .headers()
11044            .get(header::LOCATION)
11045            .unwrap()
11046            .to_str()
11047            .unwrap();
11048        assert!(
11049            loc.contains("Imported%202%20feeds"),
11050            "unexpected flash: {loc}"
11051        );
11052        assert!(
11053            !loc.contains("skipped"),
11054            "the at:// entry was skipped with the flag on: {loc}"
11055        );
11056        let stored = store::get_feed_by_url(&state.db, AT_URI_SUB).await.unwrap();
11057        assert!(
11058            stored.is_some(),
11059            "the at:// entry was not stored with the flag on"
11060        );
11061    }
11062
11063    /// **A retitle must not cache a secret-bearing URL.** Moving the privacy
11064    /// gate behind `url_changed` was right for the PDS write — the record is
11065    /// the reader's — but the cache write was gated only on `storable`, which
11066    /// any http(s) URL is. So a retitle of a record another client wrote with
11067    /// a tokened feed URL inserted that URL into the shared `feeds` table,
11068    /// where the poller would fail it every cycle and print it on the admin
11069    /// page. main refused the whole rename; this keeps the record editable and
11070    /// the cache clean, as `resolve_subscriptions` already does for the same
11071    /// record.
11072    #[tokio::test]
11073    async fn retitling_a_secret_bearing_record_does_not_cache_its_url() {
11074        let did = "did:plc:renamer5";
11075        let tokened = "https://www.patreon.com/rss/author?auth=Zm9vYmFyc2VjcmV0dG9rZW4";
11076        let tokened_enc =
11077            "https%3A%2F%2Fwww.patreon.com%2Frss%2Fauthor%3Fauth%3DZm9vYmFyc2VjcmV0dG9rZW4";
11078        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription_with_url(tokened)).await;
11079        let state = test_state_with_sidecar(&[did], &sidecar).await;
11080        let loc = retitle_unchanged(&state, did, tokened_enc).await;
11081        assert_eq!(loc, "/", "the retitle was refused: {loc}");
11082        assert_eq!(
11083            puts.lock().unwrap().len(),
11084            1,
11085            "the retitle did not reach the PDS"
11086        );
11087        assert!(
11088            store::get_feed_by_url(&state.db, tokened)
11089                .await
11090                .unwrap()
11091                .is_none(),
11092            "a secret-bearing URL was written to the shared cache by a retitle"
11093        );
11094    }
11095
11096    /// **On a repoint, storability is decided before privacy and capacity** —
11097    /// the same ordering the add path got. A malformed at:// target drew the
11098    /// private/paid flash, and at capacity a well-formed one drew "try again
11099    /// later" for a URL that can never be accepted with the flag off.
11100    #[tokio::test]
11101    async fn repointing_at_a_malformed_at_uri_is_refused_as_unsupported() {
11102        let did = "did:plc:renamer4";
11103        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
11104        let state = test_state_with_sidecar(&[did], &sidecar).await;
11105        let cookie = session_cookie(&state, did, None);
11106        let resp = router(state.clone())
11107            .oneshot(
11108                Request::builder()
11109                    .method("POST")
11110                    .uri("/subscriptions/rk-keep/rename")
11111                    .header(header::COOKIE, cookie)
11112                    .header("content-type", "application/x-www-form-urlencoded")
11113                    .body(Body::from(
11114                        "url=at%3A%2F%2Fdid%3Aplc%3ATOOSHORT%2Fsite.standard.publication%2F3lab&title=Moved",
11115                    ))
11116                    .unwrap(),
11117            )
11118            .await
11119            .unwrap();
11120        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11121        let loc = resp
11122            .headers()
11123            .get(header::LOCATION)
11124            .unwrap()
11125            .to_str()
11126            .unwrap();
11127        assert!(
11128            loc.contains("kind%20of%20feed"),
11129            "expected the unsupported flash: {loc}"
11130        );
11131        assert!(
11132            !loc.contains("Private"),
11133            "a typo was reported as a paid feed: {loc}"
11134        );
11135        assert!(puts.lock().unwrap().is_empty());
11136    }
11137
11138    #[tokio::test]
11139    async fn repointing_at_an_at_uri_at_capacity_is_refused_as_unsupported_not_capacity() {
11140        let did = "did:plc:renamer4";
11141        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
11142        let state = test_state_with_sidecar_and(&[did], &sidecar, false, 1).await;
11143        store::upsert_feed(
11144            &state.db,
11145            &store::NewFeed {
11146                url: "https://filler.example/feed.xml".to_string(),
11147                ..Default::default()
11148            },
11149        )
11150        .await
11151        .unwrap();
11152        let cookie = session_cookie(&state, did, None);
11153        let resp = router(state.clone())
11154            .oneshot(
11155                Request::builder()
11156                    .method("POST")
11157                    .uri("/subscriptions/rk-keep/rename")
11158                    .header(header::COOKIE, cookie)
11159                    .header("content-type", "application/x-www-form-urlencoded")
11160                    .body(Body::from(format!("url={AT_URI_SUB_ENC}&title=Moved")))
11161                    .unwrap(),
11162            )
11163            .await
11164            .unwrap();
11165        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11166        let loc = resp
11167            .headers()
11168            .get(header::LOCATION)
11169            .unwrap()
11170            .to_str()
11171            .unwrap();
11172        assert!(
11173            loc.contains("kind%20of%20feed"),
11174            "expected the unsupported flash: {loc}"
11175        );
11176        assert!(
11177            !loc.contains("capacity"),
11178            "an unacceptable URL was reported as a capacity problem: {loc}"
11179        );
11180        assert!(puts.lock().unwrap().is_empty());
11181    }
11182
11183    /// **`url_changed` compares like for like.** The form value is trimmed;
11184    /// the record's URL was compared raw, so a record another client wrote
11185    /// with a trailing space read as a repoint on every retitle and re-armed
11186    /// every gate — including the one that made an at:// record un-editable.
11187    #[tokio::test]
11188    async fn retitling_a_record_whose_url_carries_whitespace_is_not_a_repoint() {
11189        let did = "did:plc:renamer5";
11190        let padded = format!("{AT_URI_SUB} ");
11191        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription_with_url(&padded)).await;
11192        let state = test_state_with_sidecar(&[did], &sidecar).await;
11193        // The manage row posts the record's URL verbatim, padding included.
11194        let loc = retitle_unchanged(&state, did, &format!("{AT_URI_SUB_ENC}%20")).await;
11195        assert_eq!(
11196            loc, "/",
11197            "the retitle was treated as a repoint and refused: {loc}"
11198        );
11199        let bodies = puts.lock().unwrap().clone();
11200        assert_eq!(bodies.len(), 1);
11201        let sent: serde_json::Value = serde_json::from_str(&bodies[0]).unwrap();
11202        assert_eq!(
11203            sent["record"]["url"], AT_URI_SUB,
11204            "the padding was not normalised away"
11205        );
11206    }
11207
11208    /// **A retitle inserts no cache row.** The ceiling is checked on a repoint
11209    /// only, so the trailing upsert must not create a row for an unchanged URL
11210    /// that has none — with the flag on and the cache full, each retitle of a
11211    /// never-cached at:// record was a row past the cap. An existing row still
11212    /// gets its title kept in step.
11213    #[tokio::test]
11214    async fn retitling_an_uncached_record_at_capacity_inserts_no_row() {
11215        let did = "did:plc:renamer5";
11216        let (sidecar, puts) = spawn_rename_sidecar(seeded_at_uri_subscription()).await;
11217        let state = test_state_with_sidecar_and(&[did], &sidecar, true, 1).await;
11218        store::upsert_feed(
11219            &state.db,
11220            &store::NewFeed {
11221                url: "https://filler.example/feed.xml".to_string(),
11222                ..Default::default()
11223            },
11224        )
11225        .await
11226        .unwrap();
11227        let loc = retitle_unchanged(&state, did, AT_URI_SUB_ENC).await;
11228        assert_eq!(loc, "/", "the retitle was refused: {loc}");
11229        assert_eq!(puts.lock().unwrap().len(), 1);
11230        assert_eq!(
11231            store::count_feeds(&state.db).await.unwrap(),
11232            1,
11233            "a retitle inserted a cache row past the ceiling"
11234        );
11235    }
11236
11237    /// **The add path's at:// pre-check is about the MESSAGE, so it is
11238    /// case-insensitive.** `Url::parse` folds the scheme, so `AT://…` skipped
11239    /// the pre-check and the classifier's at:// arm alike, parsed as `at`, and
11240    /// tripped the secret heuristic on the rkey — the private/paid flash the
11241    /// pre-check exists to avoid. Storage stays case-sensitive; this does not
11242    /// touch it.
11243    #[tokio::test]
11244    async fn an_uppercase_at_scheme_paste_is_refused_as_unsupported() {
11245        let did = "did:plc:typoist";
11246        let state = test_state_with_caps(did, 0, 0).await;
11247        let cookie = session_cookie(&state, did, None);
11248        let resp = router(state.clone())
11249            .oneshot(
11250                Request::builder()
11251                    .method("POST")
11252                    .uri("/subscriptions")
11253                    .header(header::COOKIE, cookie)
11254                    .header("content-type", "application/x-www-form-urlencoded")
11255                    .body(Body::from(
11256                        "url=AT%3A%2F%2Falice.example.com%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
11257                    ))
11258                    .unwrap(),
11259            )
11260            .await
11261            .unwrap();
11262        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11263        let loc = resp
11264            .headers()
11265            .get(header::LOCATION)
11266            .unwrap()
11267            .to_str()
11268            .unwrap();
11269        assert!(
11270            loc.contains("kind%20of%20feed"),
11271            "expected the unsupported flash: {loc}"
11272        );
11273        assert!(!loc.contains("Private"), "reported as a paid feed: {loc}");
11274    }
11275
11276    /// **A rename must not destroy the fields the form never carries.**
11277    ///
11278    /// `update_subscription` is a `putRecord` — the WHOLE record is replaced, per
11279    /// its own doc. The handler built a fresh `Subscription::new(url, now())`, so
11280    /// every field absent from `templates/manage_row.html` (which posts only
11281    /// `url`, `title`, `folder`) was written back as its default:
11282    ///
11283    /// | field | before | after |
11284    /// |---|---|---|
11285    /// | `siteUrl` | whatever the feed advertised | gone |
11286    /// | `fetchHint` | as set | gone |
11287    /// | `private` | as set | gone |
11288    /// | `createdAt` | original subscribe time | reset to now |
11289    ///
11290    /// `createdAt` is the worst of the four: it is the sort key for "when did I
11291    /// subscribe", it is unrecoverable once overwritten, and nothing in the UI
11292    /// tells the reader it moved.
11293    ///
11294    /// Asserted on the BYTES THE SIDECAR RECEIVES, not on a `Subscription` built
11295    /// in the test — the record only becomes wrong on the way out, so checking
11296    /// the value we passed in would pass just as happily with the fix removed.
11297    #[tokio::test]
11298    async fn renaming_preserves_the_fields_the_form_never_carries() {
11299        let did = "did:plc:renamer4";
11300        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
11301        let state = test_state_with_sidecar(&[did], &sidecar).await;
11302        let cookie = session_cookie(&state, did, None);
11303
11304        let resp = router(state.clone())
11305            .oneshot(
11306                Request::builder()
11307                    .method("POST")
11308                    .uri("/subscriptions/rk-keep/rename")
11309                    .header(header::COOKIE, cookie)
11310                    .header("content-type", "application/x-www-form-urlencoded")
11311                    // Exactly what the manage row posts: url, title, folder.
11312                    .body(Body::from(
11313                        "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=New+title&folder=Tech",
11314                    ))
11315                    .unwrap(),
11316            )
11317            .await
11318            .unwrap();
11319        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11320
11321        let bodies = puts.lock().unwrap().clone();
11322        assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
11323        let body = &bodies[0];
11324        // Anchors the negative assertions: an empty capture would satisfy them.
11325        assert!(
11326            body.contains("community.lexicon.rss.subscription"),
11327            "captured no usable put body: {body:?}"
11328        );
11329
11330        let sent: serde_json::Value = serde_json::from_str(body).expect("put body is JSON");
11331        let record = &sent["record"];
11332
11333        // What the form DID carry must be applied.
11334        assert_eq!(record["title"], "New title", "the rename did not apply");
11335        assert_eq!(record["folder"], "Tech", "the re-folder did not apply");
11336
11337        // What the form did NOT carry must survive.
11338        assert_eq!(
11339            record["createdAt"], "2024-03-01T00:00:00.000Z",
11340            "the rename reset createdAt — the reader's subscribe time is gone \
11341             from their own repo, and nothing told them"
11342        );
11343        assert_eq!(
11344            record["siteUrl"], "https://example.com/blog",
11345            "the rename erased siteUrl"
11346        );
11347        assert_eq!(record["fetchHint"], "hourly", "the rename erased fetchHint");
11348        assert_eq!(record["private"], false, "the rename erased private");
11349    }
11350
11351    /// **Repointing at a different feed drops that feed's properties, but not
11352    /// the subscription's.**
11353    ///
11354    /// `siteUrl` and `fetchHint` describe the feed the subscription points at,
11355    /// so carrying them onto a different URL would leave a site link for the old
11356    /// feed hanging off the new one. `createdAt` and `private` are properties of
11357    /// the SUBSCRIPTION and survive a repoint — the reader subscribed when they
11358    /// subscribed, whatever the URL was later corrected to.
11359    #[tokio::test]
11360    async fn repointing_a_feed_drops_the_old_feeds_properties_but_keeps_the_subscriptions() {
11361        let did = "did:plc:renamer4";
11362        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
11363        let state = test_state_with_sidecar(&[did], &sidecar).await;
11364        let cookie = session_cookie(&state, did, None);
11365
11366        let resp = router(state.clone())
11367            .oneshot(
11368                Request::builder()
11369                    .method("POST")
11370                    .uri("/subscriptions/rk-keep/rename")
11371                    .header(header::COOKIE, cookie)
11372                    .header("content-type", "application/x-www-form-urlencoded")
11373                    // A DIFFERENT feed URL from the seeded record.
11374                    .body(Body::from(
11375                        "url=https%3A%2F%2Fother.example%2Ffeed.xml&title=Repointed",
11376                    ))
11377                    .unwrap(),
11378            )
11379            .await
11380            .unwrap();
11381        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11382
11383        let bodies = puts.lock().unwrap().clone();
11384        assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
11385        assert!(
11386            bodies[0].contains("community.lexicon.rss.subscription"),
11387            "captured no usable put body: {:?}",
11388            bodies[0]
11389        );
11390        let sent: serde_json::Value = serde_json::from_str(&bodies[0]).expect("put body is JSON");
11391        let record = &sent["record"];
11392
11393        assert_eq!(record["url"], "https://other.example/feed.xml");
11394        // The old feed's properties are gone rather than misattributed.
11395        assert!(
11396            record.get("siteUrl").is_none() || record["siteUrl"].is_null(),
11397            "the old feed's site link followed the subscription to a new feed: {record}"
11398        );
11399        assert!(
11400            record.get("fetchHint").is_none() || record["fetchHint"].is_null(),
11401            "the old feed's fetch hint followed the subscription to a new feed: {record}"
11402        );
11403        // The subscription's own properties survive.
11404        assert_eq!(
11405            record["createdAt"], "2024-03-01T00:00:00.000Z",
11406            "a repoint is still not a new subscription; createdAt must not move"
11407        );
11408        assert_eq!(record["private"], false, "the repoint erased private");
11409    }
11410
11411    /// **A rename against an rkey that is not in the repo writes NOTHING.**
11412    ///
11413    /// `update_subscription` is a `putRecord`, which CREATES the record when the
11414    /// rkey does not exist — with whatever `createdAt` we hand it. So without
11415    /// this refusal a rename against a stale or wrong rkey manufactures a
11416    /// subscription dated today, which is the bug this whole change exists to
11417    /// fix, arriving by a different door.
11418    ///
11419    /// The guard was untested when first written: removing it left all 733 tests
11420    /// green. An untested guard against the exact defect being fixed is how the
11421    /// two previous rounds of this problem got through.
11422    #[tokio::test]
11423    async fn renaming_an_unknown_rkey_writes_nothing() {
11424        let did = "did:plc:renamer4";
11425        // The sidecar serves exactly one record, at rkey `rk-keep`.
11426        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
11427        let state = test_state_with_sidecar(&[did], &sidecar).await;
11428        let cookie = session_cookie(&state, did, None);
11429
11430        let resp = router(state.clone())
11431            .oneshot(
11432                Request::builder()
11433                    .method("POST")
11434                    // ...and this is not it.
11435                    .uri("/subscriptions/rk-does-not-exist/rename")
11436                    .header(header::COOKIE, cookie)
11437                    .header("content-type", "application/x-www-form-urlencoded")
11438                    .body(Body::from(
11439                        "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=Ghost",
11440                    ))
11441                    .unwrap(),
11442            )
11443            .await
11444            .unwrap();
11445
11446        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11447        let loc = resp
11448            .headers()
11449            .get(header::LOCATION)
11450            .unwrap()
11451            .to_str()
11452            .unwrap();
11453        assert!(
11454            loc.contains("flash="),
11455            "an unknown rkey redirected as though the rename had worked: {loc}"
11456        );
11457        assert!(
11458            puts.lock().unwrap().is_empty(),
11459            "a rename against an unknown rkey wrote a record — putRecord would \
11460             CREATE it, dated today: {:?}",
11461            puts.lock().unwrap()
11462        );
11463    }
11464
11465    /// **A `site_url` the client actually sends is applied, not dropped.**
11466    ///
11467    /// `templates/manage_row.html` does not post this field, so it is tempting
11468    /// to read the arm that handles it as dead code. It is not:
11469    /// `RenameSubForm` carries `site_url`, so a hand-crafted POST reaches it
11470    /// today. Discarding the value instead of applying it left all 733 tests
11471    /// green.
11472    ///
11473    /// The value is scheme-checked on the way out by the repo-boundary vet, so
11474    /// this is a coverage gap rather than an exposure — but an untested path
11475    /// that writes a URL into the reader's PDS should not stay untested.
11476    #[tokio::test]
11477    async fn a_client_supplied_site_url_reaches_the_record() {
11478        let did = "did:plc:renamer4";
11479        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
11480        let state = test_state_with_sidecar(&[did], &sidecar).await;
11481        let cookie = session_cookie(&state, did, None);
11482
11483        let resp = router(state.clone())
11484            .oneshot(
11485                Request::builder()
11486                    .method("POST")
11487                    .uri("/subscriptions/rk-keep/rename")
11488                    .header(header::COOKIE, cookie)
11489                    .header("content-type", "application/x-www-form-urlencoded")
11490                    // Same feed URL, but carrying a site_url the manage row
11491                    // never sends.
11492                    .body(Body::from(
11493                        "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=Kept\
11494                         &site_url=https%3A%2F%2Ftyped.example%2Fsite",
11495                    ))
11496                    .unwrap(),
11497            )
11498            .await
11499            .unwrap();
11500        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11501
11502        let bodies = puts.lock().unwrap().clone();
11503        assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
11504        assert!(
11505            bodies[0].contains("community.lexicon.rss.subscription"),
11506            "captured no usable put body: {:?}",
11507            bodies[0]
11508        );
11509        let sent: serde_json::Value = serde_json::from_str(&bodies[0]).expect("put body is JSON");
11510        assert_eq!(
11511            sent["record"]["siteUrl"], "https://typed.example/site",
11512            "the client's siteUrl was dropped; the seeded record's survived instead"
11513        );
11514    }
11515
11516    /// **A rename whose read fails writes NOTHING.**
11517    ///
11518    /// This is the property most easily lost when someone later touches this
11519    /// handler: falling back to `Subscription::new` on a read error looks like
11520    /// graceful degradation and is in fact the original bug, reinstated on
11521    /// exactly the path where it is hardest to notice. The reader must be told
11522    /// instead.
11523    #[tokio::test]
11524    async fn a_rename_whose_read_fails_writes_nothing() {
11525        let did = "did:plc:renamer5";
11526        // A port that accepts nothing: the read cannot succeed.
11527        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
11528        let dead = format!("http://{}", listener.local_addr().unwrap());
11529        drop(listener);
11530
11531        let state = test_state_with_sidecar(&[did], &dead).await;
11532        let cookie = session_cookie(&state, did, None);
11533        let before = store::count_feeds(&state.db).await.unwrap();
11534
11535        let resp = router(state.clone())
11536            .oneshot(
11537                Request::builder()
11538                    .method("POST")
11539                    .uri("/subscriptions/rk-keep/rename")
11540                    .header(header::COOKIE, cookie)
11541                    .header("content-type", "application/x-www-form-urlencoded")
11542                    .body(Body::from(
11543                        "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=Doomed",
11544                    ))
11545                    .unwrap(),
11546            )
11547            .await
11548            .unwrap();
11549
11550        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11551        let loc = resp
11552            .headers()
11553            .get(header::LOCATION)
11554            .unwrap()
11555            .to_str()
11556            .unwrap();
11557        assert!(
11558            loc.contains("flash="),
11559            "a failed read redirected as though the rename had worked: {loc}"
11560        );
11561        assert_eq!(
11562            store::count_feeds(&state.db).await.unwrap(),
11563            before,
11564            "a rename that could not read the record still wrote to the cache"
11565        );
11566    }
11567
11568    /// Folder pre-selection regression: the manage rename row must mark the
11569    /// feed's CURRENT folder `<option>` as `selected`, so an untouched Save
11570    /// re-submits the current folder instead of silently un-foldering the feed.
11571    /// A loose (un-foldered) feed must mark "No folder" selected instead. Renders
11572    /// `ManageTemplate` directly so no PDS/sidecar round-trip is needed.
11573    #[test]
11574    fn manage_rename_row_preselects_current_folder() {
11575        let nav = Nav {
11576            handle: "@reader.example".to_string(),
11577            avatar: "RE".to_string(),
11578            view: "unread".to_string(),
11579            scope_qs: String::new(),
11580            folders: Vec::new(),
11581            loose_feeds: Vec::new(),
11582            manage_active: true,
11583        };
11584        let folder_options = vec![
11585            FolderOption {
11586                uri: "at://did:plc:x/app.folder/work".to_string(),
11587                name: "Work".to_string(),
11588            },
11589            FolderOption {
11590                uri: "at://did:plc:x/app.folder/fun".to_string(),
11591                name: "Fun".to_string(),
11592            },
11593        ];
11594        // A foldered feed (in "Work") and a loose feed (no folder), each with a
11595        // non-empty rkey so the rename form renders.
11596        let foldered = FeedView {
11597            rkey: "sub-foldered".to_string(),
11598            url: "https://work.example/feed.xml".to_string(),
11599            title: "Work Feed".to_string(),
11600            unread: 0,
11601            selected: false,
11602            folder: Some("at://did:plc:x/app.folder/work".to_string()),
11603        };
11604        let loose = FeedView {
11605            rkey: "sub-loose".to_string(),
11606            url: "https://loose.example/feed.xml".to_string(),
11607            title: "Loose Feed".to_string(),
11608            unread: 0,
11609            selected: false,
11610            folder: None,
11611        };
11612        let tmpl = ManageTemplate {
11613            card: Card::private(&Config::default()),
11614            version: VERSION,
11615            repo_url: REPO_URL,
11616            kofi_url: KOFI_URL,
11617            flash: String::new(),
11618            alert: String::new(),
11619            nav,
11620            folder_options,
11621            folders: vec![FolderView {
11622                rkey: "folder-work".to_string(),
11623                uri: "at://did:plc:x/app.folder/work".to_string(),
11624                name: "Work".to_string(),
11625                feeds: vec![foldered],
11626                selected: false,
11627            }],
11628            loose_feeds: vec![loose],
11629            standard_site: false,
11630        };
11631        let html = tmpl.render().unwrap();
11632
11633        // The foldered feed's "Work" option is pre-selected.
11634        assert!(
11635            html.contains(
11636                r#"<option value="at://did:plc:x/app.folder/work" selected>Work</option>"#
11637            ),
11638            "foldered feed must pre-select its current folder: {html}"
11639        );
11640        // The loose feed's "No folder" option is pre-selected (appears for the
11641        // loose row, which has folder=None).
11642        assert!(
11643            html.contains(r#"<option value="" selected>No folder</option>"#),
11644            "loose feed must pre-select 'No folder': {html}"
11645        );
11646    }
11647
11648    /// **The public stats page carries no user data.**
11649    ///
11650    /// It is reachable by anyone, so the thing worth pinning is what it does
11651    /// NOT say: nothing about how many people use the instance, nothing about
11652    /// which feeds fail, nothing about who reads what.
11653    #[tokio::test]
11654    async fn the_public_stats_page_exposes_no_user_data() {
11655        let state = test_state(&[]).await;
11656        store::ensure_seed(&state.db, &["did:plc:someone".to_string()])
11657            .await
11658            .unwrap();
11659
11660        let resp = router(state)
11661            .oneshot(
11662                Request::builder()
11663                    .uri("/stats")
11664                    .body(Body::empty())
11665                    .unwrap(),
11666            )
11667            .await
11668            .unwrap();
11669        assert_eq!(resp.status(), StatusCode::OK, "stats must be public");
11670
11671        let body = String::from_utf8(
11672            axum::body::to_bytes(resp.into_body(), usize::MAX)
11673                .await
11674                .unwrap()
11675                .to_vec(),
11676        )
11677        .unwrap();
11678
11679        // Structural checks, not word checks. The page's own prose says it
11680        // publishes no error rates, so searching for that PHRASE finds the
11681        // disclaimer rather than a leak — the first version of this test failed
11682        // on exactly that. What matters is whether identifiers or the
11683        // admin-only figures are present.
11684        assert!(
11685            !body.contains("did:"),
11686            "the public stats page leaked an identifier"
11687        );
11688        for admin_only in ["errp50ms", "p95ms", "live backend", "ok_count"] {
11689            assert!(
11690                !body.contains(admin_only),
11691                "the public page is showing the admin metrics column {admin_only:?}"
11692            );
11693        }
11694        // And it does render the aggregate it exists for.
11695        assert!(body.contains("Feeds tracked"));
11696        assert!(body.contains("Waiting to be polled"));
11697    }
11698
11699    /// **The two states that stop feeds updating must be visible.**
11700    ///
11701    /// `overdue` and `polled_last_hour` move in BOTH and distinguish neither —
11702    /// and `overdue` moves the WRONG WAY for backoff, because backoff is applied
11703    /// by pushing `next_poll` forward, so a feed failing every fetch drops out of
11704    /// the backlog and makes the page read healthier. That inversion is what this
11705    /// test pins: a broken feed must raise a number, not lower one.
11706    #[tokio::test]
11707    async fn stats_distinguishes_backoff_from_a_watermark_pause() {
11708        let state = test_state(&[]).await;
11709        // Three feeds: one healthy, one flaky, one long dead.
11710        for (url, errors) in [
11711            ("https://ok.example/f.xml", 0),
11712            ("https://flaky.example/f.xml", 2),
11713            ("https://dead.example/f.xml", 9),
11714        ] {
11715            store::upsert_feed(
11716                &state.db,
11717                &store::NewFeed {
11718                    url: url.to_string(),
11719                    // Pushed forward, exactly as backoff does — so none of these
11720                    // are counted as `overdue`.
11721                    next_poll: Some("2099-01-01T00:00:00Z".to_string()),
11722                    ..Default::default()
11723                },
11724            )
11725            .await
11726            .unwrap();
11727            for _ in 0..errors {
11728                store::bump_feed_errors(
11729                    &state.db,
11730                    url,
11731                    feed::FailureKind::Fetch,
11732                    "connection refused",
11733                )
11734                .await
11735                .unwrap();
11736            }
11737        }
11738
11739        let render_stats = |state: AppState| async move {
11740            let resp = router(state)
11741                .oneshot(
11742                    Request::builder()
11743                        .uri("/stats")
11744                        .body(Body::empty())
11745                        .unwrap(),
11746                )
11747                .await
11748                .unwrap();
11749            assert_eq!(resp.status(), StatusCode::OK);
11750            String::from_utf8(
11751                axum::body::to_bytes(resp.into_body(), usize::MAX)
11752                    .await
11753                    .unwrap()
11754                    .to_vec(),
11755            )
11756            .unwrap()
11757        };
11758
11759        // **The fixture must actually be RUNNING, or this test measures nothing.**
11760        // `test_state` leaves `schedulers_enabled` false, and `fetching_state`
11761        // checks that BEFORE the watermark — so without these two lines every
11762        // render below reports "off" and the watermark can never surface. The
11763        // assertions still passed, for reasons unrelated to what they name: see
11764        // the two comments below.
11765        state.runtime_health.set_schedulers_enabled(true);
11766        state
11767            .runtime_health
11768            .poll_tick_completed(crate::store::now_unix());
11769
11770        let body = render_stats(state.clone()).await;
11771        assert!(
11772            body.contains("Failing"),
11773            "backoff is still invisible on the public page"
11774        );
11775        // 2 failing, 1 of them badly (>= BADLY_BROKEN_ERRORS). Matched on the
11776        // value rather than on surrounding whitespace, so re-indenting the
11777        // template cannot break this.
11778        assert!(
11779            body.contains("2, 1 badly"),
11780            "expected '2, 1 badly' in the failing row; got:\n{}",
11781            body.split("Failing")
11782                .nth(1)
11783                .unwrap_or("")
11784                .chars()
11785                .take(300)
11786                .collect::<String>()
11787        );
11788        // Not paused, and the backlog is genuinely empty — which is exactly the
11789        // reading that used to be indistinguishable from healthy.
11790        //
11791        // **Asserted by EXCLUDING the other states, not by matching "running".**
11792        // The `off` row reads "the poller is not running on this instance", which
11793        // contains "running" — so the bare substring passed while the page was
11794        // reporting the exact opposite of what this line claims to check.
11795        assert!(
11796            !body.contains("the poller is not running")
11797                && !body.contains("the cache is at its size limit")
11798                && !body.contains("has not completed a round"),
11799            "expected the running state; the page reported a stopped one",
11800        );
11801
11802        // Now trip the watermark. Nothing in the database changes; only the
11803        // recorded runtime state does — which is the whole reason it needed a
11804        // home outside the log stream.
11805        state.runtime_health.set_watermark(true);
11806        let paused = render_stats(state.clone()).await;
11807        // Matched on the paused row's OWN sentence. The bare word "paused" also
11808        // appeared in the page's explanatory prose, so this assertion passed
11809        // whether or not the row rendered — and trimming that prose is what
11810        // exposed it. This phrase exists only inside the `paused` branch.
11811        assert!(
11812            paused.contains("the cache is at its size limit"),
11813            "a watermark pause is still invisible on the public page"
11814        );
11815
11816        // Still no identifiers: these are counts, not feeds.
11817        for leak in ["ok.example", "flaky.example", "dead.example", "did:"] {
11818            assert!(
11819                !paused.contains(leak),
11820                "the public page leaked {leak:?} while reporting failures"
11821            );
11822        }
11823    }
11824
11825    /// **`/admin/metrics` is gated, and nothing checked that it was.**
11826    ///
11827    /// Deleting the `admin_seed_dids` check left the entire suite green. That
11828    /// was survivable while the page held only aggregate timings; it is not now,
11829    /// because this branch puts **per-feed URLs and remote error text** behind
11830    /// that gate. A guarantee nothing checks is a comment, and this one is now
11831    /// the only thing standing between a signed-in stranger and the operational
11832    /// picture the handler's own doc says is not public.
11833    ///
11834    /// All three doors: no session, a session that is not an admin, and the
11835    /// admin itself.
11836    #[tokio::test]
11837    async fn admin_metrics_is_refused_to_everyone_but_an_admin() {
11838        let admin = "did:plc:adminseed";
11839        // **Only the admin is in ALLOWED_DIDS**, because `admin_seed_dids()`
11840        // IS that list — deliberately, per its doc: "the same people I trust on
11841        // this instance". Production sets it to the bootstrap DID alone.
11842        //
11843        // A genuine non-admin is therefore someone holding a beta seat granted
11844        // by an invite, not by the allow-list. Seeding both would have made
11845        // both admins and quietly turned the 403 assertion below into a test of
11846        // nothing — which is exactly what the first draft of this did.
11847        let state = test_state(&[admin]).await;
11848        store::grant_access(&state.db, "did:plc:ordinaryuser", None, "invite", None)
11849            .await
11850            .unwrap();
11851        let url = "https://broken.example/f.xml";
11852        store::upsert_feed(
11853            &state.db,
11854            &store::NewFeed {
11855                url: url.to_string(),
11856                ..Default::default()
11857            },
11858        )
11859        .await
11860        .unwrap();
11861        store::bump_feed_errors(
11862            &state.db,
11863            url,
11864            feed::FailureKind::Fetch,
11865            "SENTINEL_ADMIN_ONLY",
11866        )
11867        .await
11868        .unwrap();
11869
11870        let get = |state: AppState, cookie: Option<String>| async move {
11871            let mut req = Request::builder().uri("/admin/metrics");
11872            if let Some(c) = cookie {
11873                req = req.header(header::COOKIE, c);
11874            }
11875            let resp = router(state)
11876                .oneshot(req.body(Body::empty()).unwrap())
11877                .await
11878                .unwrap();
11879            let status = resp.status();
11880            let body = String::from_utf8(
11881                axum::body::to_bytes(resp.into_body(), usize::MAX)
11882                    .await
11883                    .unwrap()
11884                    .to_vec(),
11885            )
11886            .unwrap();
11887            (status, body)
11888        };
11889
11890        // No session at all.
11891        let (status, body) = get(state.clone(), None).await;
11892        assert_eq!(status, StatusCode::UNAUTHORIZED);
11893        assert!(
11894            !body.contains("SENTINEL_ADMIN_ONLY"),
11895            "leaked to anonymous: {body}"
11896        );
11897
11898        // A real, signed-in user who is not an admin.
11899        let ordinary = session_cookie(&state, "did:plc:ordinaryuser", None);
11900        let (status, body) = get(state.clone(), Some(ordinary)).await;
11901        assert_eq!(
11902            status,
11903            StatusCode::FORBIDDEN,
11904            "a non-admin session was let in"
11905        );
11906        assert!(
11907            !body.contains("SENTINEL_ADMIN_ONLY") && !body.contains("broken.example"),
11908            "leaked to a non-admin: {body}",
11909        );
11910
11911        // The admin does get it — otherwise the two refusals above are
11912        // satisfied by the endpoint being broken for everyone.
11913        let admin_cookie = session_cookie(&state, admin, None);
11914        let (status, body) = get(state, Some(admin_cookie)).await;
11915        assert_eq!(status, StatusCode::OK);
11916        assert!(
11917            body.contains("SENTINEL_ADMIN_ONLY"),
11918            "admin cannot see it: {body}"
11919        );
11920    }
11921
11922    /// **The cause a public count cannot carry belongs on the admin page.**
11923    ///
11924    /// The public histogram is four coarse buckets, and `fetch` is the coarsest:
11925    /// #159's own error — `guarded_get` bailing on a 304 — lands there beside
11926    /// DNS failure, timeout and SSRF refusal. So the histogram alone would NOT
11927    /// have separated "sixty dead publishers" from "one bug here", which is the
11928    /// case it was justified by.
11929    ///
11930    /// The answer is not a finer public vocabulary — `/stats` promises never
11931    /// which feed and never whose, and a bucket per error string would break
11932    /// that. It is to put the detail where per-feed data is already allowed.
11933    /// `/admin/metrics` is gated on `ALLOWED_DIDS` and already carries an
11934    /// operational picture.
11935    ///
11936    /// Asserts both halves: the detail IS on the admin page, and is NOT on the
11937    /// public one.
11938    #[tokio::test]
11939    async fn the_admin_page_names_failing_feeds_and_the_public_page_does_not() {
11940        let admin = "did:plc:adminseed";
11941        let state = test_state(&[admin]).await;
11942        let url = "https://broken.example/f.xml";
11943        store::upsert_feed(
11944            &state.db,
11945            &store::NewFeed {
11946                url: url.to_string(),
11947                ..Default::default()
11948            },
11949        )
11950        .await
11951        .unwrap();
11952        store::bump_feed_errors(
11953            &state.db,
11954            url,
11955            feed::FailureKind::Fetch,
11956            "SENTINEL_REDIRECT_NO_LOCATION",
11957        )
11958        .await
11959        .unwrap();
11960
11961        let cookie = session_cookie(&state, admin, None);
11962        let resp = router(state.clone())
11963            .oneshot(
11964                Request::builder()
11965                    .uri("/admin/metrics")
11966                    .header(header::COOKIE, cookie)
11967                    .body(Body::empty())
11968                    .unwrap(),
11969            )
11970            .await
11971            .unwrap();
11972        assert_eq!(resp.status(), StatusCode::OK);
11973        let admin_body = String::from_utf8(
11974            axum::body::to_bytes(resp.into_body(), usize::MAX)
11975                .await
11976                .unwrap()
11977                .to_vec(),
11978        )
11979        .unwrap();
11980        assert!(
11981            admin_body.contains("SENTINEL_REDIRECT_NO_LOCATION"),
11982            "the admin page does not carry the failure detail: {admin_body}",
11983        );
11984        assert!(
11985            admin_body.contains("broken.example"),
11986            "the admin page does not name the failing feed: {admin_body}",
11987        );
11988
11989        // The public page still carries neither.
11990        let resp = router(state)
11991            .oneshot(
11992                Request::builder()
11993                    .uri("/stats")
11994                    .body(Body::empty())
11995                    .unwrap(),
11996            )
11997            .await
11998            .unwrap();
11999        let public = String::from_utf8(
12000            axum::body::to_bytes(resp.into_body(), usize::MAX)
12001                .await
12002                .unwrap()
12003                .to_vec(),
12004        )
12005        .unwrap();
12006        for secret in ["SENTINEL_REDIRECT_NO_LOCATION", "broken.example"] {
12007            assert!(
12008                !public.contains(secret),
12009                "{secret:?} reached the PUBLIC stats page: {public}",
12010            );
12011        }
12012    }
12013
12014    /// **A direct poll must settle the error columns, like the scheduler does.**
12015    ///
12016    /// `add_subscription` polls through `feed::poll_feed` rather than the
12017    /// scheduler, and `poll_feed` writes validators and `last_polled` but never
12018    /// touches `consecutive_errors` — that is the scheduler's job, and this path
12019    /// is not the scheduler.
12020    ///
12021    /// So a feed that was failing, is re-subscribed, and polls SUCCESSFULLY kept
12022    /// its old count and its old cause: the public page went on reporting it
12023    /// under `Failing`, under `badly_broken`, and under a cause, for as long as
12024    /// the stale backoff horizon lasted — up to 24h — while the reader was
12025    /// demonstrably fetching it.
12026    #[tokio::test]
12027    async fn a_successful_direct_poll_clears_a_stale_failure() {
12028        let state = test_state(&[]).await;
12029        let url = "https://recovered.example/f.xml";
12030        store::upsert_feed(
12031            &state.db,
12032            &store::NewFeed {
12033                url: url.to_string(),
12034                ..Default::default()
12035            },
12036        )
12037        .await
12038        .unwrap();
12039        store::bump_feed_errors(&state.db, url, feed::FailureKind::Fetch, "SENTINEL_OLD")
12040            .await
12041            .unwrap();
12042        // Park it on a stale backoff horizon, as a real failing feed would be.
12043        sqlx::query("UPDATE feeds SET next_poll = '2099-01-01T00:00:00Z' WHERE url = ?1")
12044            .bind(url)
12045            .execute(&state.db)
12046            .await
12047            .unwrap();
12048
12049        // The publisher is fixed: a successful poll happens on this path.
12050        feed::settle_poll(
12051            &state.db,
12052            url,
12053            &feed::PollOutcome::NotModified,
12054            state.config.poll_interval,
12055        )
12056        .await;
12057
12058        let row: (i64, Option<String>, Option<String>) = sqlx::query_as(
12059            "SELECT consecutive_errors, last_error_kind, next_poll FROM feeds WHERE url = ?1",
12060        )
12061        .bind(url)
12062        .fetch_one(&state.db)
12063        .await
12064        .unwrap();
12065        assert_eq!(row.0, 0, "a successful direct poll left the error streak");
12066        assert_eq!(row.1, None, "a successful direct poll left a stale cause");
12067        // **The half the first fix missed.** Clearing the count fixed the
12068        // REPORTING; the feed stayed parked until 2099. A working feed must be
12069        // rescheduled on its normal cadence, not left on the failure horizon.
12070        let next = row.2.expect("next_poll was cleared to NULL");
12071        // Not merely "moved off 2099" — rescheduled on the CADENCE, not a
12072        // backoff. A mutation that reschedules successes with backoff_for(1)
12073        // (5 min) also moves it off 2099, so the interval is asserted.
12074        let parsed = chrono::DateTime::parse_from_rfc3339(&next).unwrap();
12075        let delta = parsed
12076            .signed_duration_since(chrono::Utc::now())
12077            .num_seconds();
12078        let cadence = state.config.poll_interval.as_secs() as i64;
12079        assert!(
12080            (cadence - 60..=cadence + 60).contains(&delta),
12081            "expected rescheduling on the {cadence}s cadence, got {delta}s (next_poll={next})"
12082        );
12083    }
12084
12085    /// The mirror case: a first poll that FAILS must be visible at all.
12086    ///
12087    /// `Ok(outcome) => info!(...)` discarded a `PollOutcome::Failed`, so a
12088    /// subscription whose very first fetch failed sat at `consecutive_errors = 0`
12089    /// with a NULL cause — invisible to the page built to count exactly that.
12090    #[tokio::test]
12091    async fn a_failing_direct_poll_is_recorded() {
12092        let state = test_state(&[]).await;
12093        let url = "https://born-broken.example/f.xml";
12094        store::upsert_feed(
12095            &state.db,
12096            &store::NewFeed {
12097                url: url.to_string(),
12098                ..Default::default()
12099            },
12100        )
12101        .await
12102        .unwrap();
12103
12104        feed::settle_poll(
12105            &state.db,
12106            url,
12107            &feed::PollOutcome::Failed {
12108                backoff: std::time::Duration::from_secs(300),
12109                kind: feed::FailureKind::Parse,
12110                detail: "SENTINEL_BORN_BROKEN".to_string(),
12111            },
12112            state.config.poll_interval,
12113        )
12114        .await;
12115
12116        let row: (i64, Option<String>, Option<String>) = sqlx::query_as(
12117            "SELECT consecutive_errors, last_error_kind, next_poll FROM feeds WHERE url = ?1",
12118        )
12119        .bind(url)
12120        .fetch_one(&state.db)
12121        .await
12122        .unwrap();
12123        assert_eq!(row.0, 1, "a failed first poll was not counted");
12124        assert_eq!(
12125            row.1.as_deref(),
12126            Some("parse"),
12127            "its cause was not recorded"
12128        );
12129        // And it is BACKED OFF on the schedule the scheduler would use — not
12130        // left with a NULL next_poll that `due_feeds` sorts first and re-polls
12131        // on the very next tick.
12132        let next = row.2.expect("a failed direct poll left next_poll NULL");
12133        let parsed = chrono::DateTime::parse_from_rfc3339(&next).unwrap();
12134        let delta = parsed
12135            .signed_duration_since(chrono::Utc::now())
12136            .num_seconds();
12137        assert!(
12138            (240..=360).contains(&delta),
12139            "expected ~300s backoff after one failure, got {delta}s (next_poll={next})"
12140        );
12141    }
12142
12143    /// **The breakdown must sum to the Failing figure above it.**
12144    ///
12145    /// The histogram counts `last_error_kind IS NOT NULL`; `Failing` counts
12146    /// `consecutive_errors > 0`. On a migrated database every row that was
12147    /// already failing has a NULL kind — correctly, it was never recorded — so
12148    /// the two do not reconcile and the page shows "70 failing" beside "3
12149    /// fetch" with 67 silently unaccounted for. On deploy day the row vanishes
12150    /// entirely while the prose still promises a breakdown.
12151    ///
12152    /// An explicit `unknown` bucket is the honest shape: the page says how many
12153    /// it cannot explain rather than omitting them.
12154    #[tokio::test]
12155    async fn the_failure_breakdown_accounts_for_every_failing_feed() {
12156        let state = test_state(&[]).await;
12157        // Two legacy rows: failing, with no recorded cause.
12158        for url in [
12159            "https://legacy1.example/f.xml",
12160            "https://legacy2.example/f.xml",
12161        ] {
12162            store::upsert_feed(
12163                &state.db,
12164                &store::NewFeed {
12165                    url: url.to_string(),
12166                    next_poll: Some("2099-01-01T00:00:00Z".to_string()),
12167                    ..Default::default()
12168                },
12169            )
12170            .await
12171            .unwrap();
12172            sqlx::query("UPDATE feeds SET consecutive_errors = 4 WHERE url = ?1")
12173                .bind(url)
12174                .execute(&state.db)
12175                .await
12176                .unwrap();
12177        }
12178        // One row with a recorded cause.
12179        store::upsert_feed(
12180            &state.db,
12181            &store::NewFeed {
12182                url: "https://known.example/f.xml".to_string(),
12183                next_poll: Some("2099-01-01T00:00:00Z".to_string()),
12184                ..Default::default()
12185            },
12186        )
12187        .await
12188        .unwrap();
12189        store::bump_feed_errors(
12190            &state.db,
12191            "https://known.example/f.xml",
12192            feed::FailureKind::Status,
12193            "SENTINEL",
12194        )
12195        .await
12196        .unwrap();
12197
12198        let now = chrono::Utc::now();
12199        let health = store::poll_health(
12200            &state.db,
12201            &now.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
12202            &(now - chrono::Duration::hours(1)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
12203        )
12204        .await
12205        .unwrap();
12206        let counted: i64 = health.failure_kinds.iter().map(|(_, n)| n).sum();
12207        assert_eq!(
12208            counted, health.in_backoff,
12209            "the breakdown ({counted}) does not account for all {} failing feeds: {:?}",
12210            health.in_backoff, health.failure_kinds,
12211        );
12212        assert!(
12213            health
12214                .failure_kinds
12215                .iter()
12216                .any(|(k, n)| k == "unknown" && *n == 2),
12217            "no unknown bucket for the legacy rows: {:?}",
12218            health.failure_kinds,
12219        );
12220    }
12221
12222    /// **The breakdown is ordered by count, and the assertion can see it.**
12223    ///
12224    /// The first version of this asserted with three `contains` calls, which
12225    /// cannot observe order — deleting `ORDER BY` from the query passed.
12226    #[tokio::test]
12227    async fn the_failure_breakdown_is_ordered_by_count() {
12228        let state = test_state(&[]).await;
12229        for (url, kind, n) in [
12230            ("https://p1.example/f.xml", feed::FailureKind::Parse, 1),
12231            ("https://f1.example/f.xml", feed::FailureKind::Fetch, 1),
12232            ("https://f2.example/f.xml", feed::FailureKind::Fetch, 1),
12233            ("https://f3.example/f.xml", feed::FailureKind::Fetch, 1),
12234            ("https://s1.example/f.xml", feed::FailureKind::Status, 1),
12235            ("https://s2.example/f.xml", feed::FailureKind::Status, 1),
12236        ] {
12237            store::upsert_feed(
12238                &state.db,
12239                &store::NewFeed {
12240                    url: url.to_string(),
12241                    next_poll: Some("2099-01-01T00:00:00Z".to_string()),
12242                    ..Default::default()
12243                },
12244            )
12245            .await
12246            .unwrap();
12247            for _ in 0..n {
12248                store::bump_feed_errors(&state.db, url, kind, "d")
12249                    .await
12250                    .unwrap();
12251            }
12252        }
12253        let now = chrono::Utc::now();
12254        let health = store::poll_health(
12255            &state.db,
12256            &now.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
12257            &(now - chrono::Duration::hours(1)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
12258        )
12259        .await
12260        .unwrap();
12261        let labels: Vec<&str> = health
12262            .failure_kinds
12263            .iter()
12264            .map(|(k, _)| k.as_str())
12265            .collect();
12266        assert_eq!(
12267            labels,
12268            ["fetch", "status", "parse"],
12269            "not ordered by count, descending: {:?}",
12270            health.failure_kinds,
12271        );
12272    }
12273
12274    /// **Failing feeds are grouped by CAUSE, and still never named.**
12275    ///
12276    /// `badly_broken` could say that sixty feeds were failing and not whether
12277    /// that was sixty dead publishers or one bug here. It was the latter — #159,
12278    /// a `304 Not Modified` read as a malformed redirect — and the page could
12279    /// not say so, which is most of why it went unexamined.
12280    ///
12281    /// The second half of this test is the constraint that shapes the first:
12282    /// `/stats` is public and promises machines-not-people, *never which feed
12283    /// and never whose*. A histogram of causes keeps that promise; a list of
12284    /// failing URLs would break it, and is the obvious way to build this.
12285    #[tokio::test]
12286    async fn stats_groups_failures_by_cause_without_naming_any_feed() {
12287        let state = test_state(&[]).await;
12288        for (url, kind, detail, errors) in [
12289            // Detail strings are distinctive SENTINELS, not plausible English.
12290            // A first pass used "not a feed", which the page's own explanation
12291            // of the `parse` kind contains verbatim — the privacy assertion
12292            // fired on static copy rather than on a leak. A sentinel cannot
12293            // collide with prose.
12294            (
12295                "https://a.example/f.xml",
12296                feed::FailureKind::Fetch,
12297                "SENTINEL_CONNREFUSED",
12298                3,
12299            ),
12300            (
12301                "https://b.example/f.xml",
12302                feed::FailureKind::Fetch,
12303                "SENTINEL_DNSFAIL",
12304                2,
12305            ),
12306            (
12307                "https://c.example/f.xml",
12308                feed::FailureKind::Status,
12309                "SENTINEL_404",
12310                1,
12311            ),
12312            (
12313                "https://d.example/f.xml",
12314                feed::FailureKind::Parse,
12315                "SENTINEL_UNPARSEABLE",
12316                1,
12317            ),
12318        ] {
12319            store::upsert_feed(
12320                &state.db,
12321                &store::NewFeed {
12322                    url: url.to_string(),
12323                    next_poll: Some("2099-01-01T00:00:00Z".to_string()),
12324                    ..Default::default()
12325                },
12326            )
12327            .await
12328            .unwrap();
12329            for _ in 0..errors {
12330                store::bump_feed_errors(&state.db, url, kind, detail)
12331                    .await
12332                    .unwrap();
12333            }
12334        }
12335
12336        let resp = router(state.clone())
12337            .oneshot(
12338                Request::builder()
12339                    .uri("/stats")
12340                    .body(Body::empty())
12341                    .unwrap(),
12342            )
12343            .await
12344            .unwrap();
12345        assert_eq!(resp.status(), StatusCode::OK);
12346        let body = String::from_utf8(
12347            axum::body::to_bytes(resp.into_body(), usize::MAX)
12348                .await
12349                .unwrap()
12350                .to_vec(),
12351        )
12352        .unwrap();
12353
12354        // Descending by count: two fetch, then one each, tie-broken by name.
12355        assert!(
12356            body.contains("2 fetch") && body.contains("1 status") && body.contains("1 parse"),
12357            "the cause histogram did not render: {body}",
12358        );
12359
12360        // **The privacy half.** No feed URL, host, or error detail reaches the
12361        // public page — only counts by kind.
12362        for secret in [
12363            "a.example",
12364            "b.example",
12365            "c.example",
12366            "d.example",
12367            "SENTINEL_CONNREFUSED",
12368            "SENTINEL_DNSFAIL",
12369            "SENTINEL_404",
12370            "SENTINEL_UNPARSEABLE",
12371        ] {
12372            assert!(
12373                !body.contains(secret),
12374                "{secret:?} reached the PUBLIC stats page: {body}",
12375            );
12376        }
12377    }
12378
12379    /// `/health` must prove the process can reach its database, and must report
12380    /// the loop state without letting it change the status code.
12381    #[tokio::test]
12382    async fn health_checks_the_database_and_reports_the_loops() {
12383        let state = test_state(&[]).await;
12384        let body_of = |state: AppState| async move {
12385            let resp = router(state)
12386                .oneshot(
12387                    Request::builder()
12388                        .uri("/health")
12389                        .body(Body::empty())
12390                        .unwrap(),
12391                )
12392                .await
12393                .unwrap();
12394            let status = resp.status();
12395            let body = String::from_utf8(
12396                axum::body::to_bytes(resp.into_body(), usize::MAX)
12397                    .await
12398                    .unwrap()
12399                    .to_vec(),
12400            )
12401            .unwrap();
12402            (status, body)
12403        };
12404
12405        // The boot stamp is what `main` sets; the router alone does not, so this
12406        // starts "unknown" and the uptime branch below drives it explicitly.
12407        state
12408            .runtime_health
12409            .set_started_at(chrono::Utc::now().timestamp());
12410
12411        let (status, body) = body_of(state.clone()).await;
12412        assert_eq!(status, StatusCode::OK);
12413        assert!(
12414            body.contains("db: ok"),
12415            "health did not probe the DB: {body}"
12416        );
12417        assert!(
12418            body.contains("uptime:"),
12419            "no uptime — the first thing anyone asks about a container that may \
12420             be restarting: {body}"
12421        );
12422        assert!(body.contains("poller:"), "no scheduler heartbeat: {body}");
12423        assert!(body.contains("polling-paused: no"), "{body}");
12424        assert!(body.contains("backend:"), "{body}");
12425        assert!(body.contains("oauth-runtime:"), "{body}");
12426
12427        // A watermark pause is REPORTED but must not fail the check. A failed
12428        // check DEREGISTERS this machine from the proxy — and it is the only
12429        // machine — so it would turn "feeds are behind" into "the site is down"
12430        // for as long as the disk stays full.
12431        state.runtime_health.set_watermark(true);
12432        state.runtime_health.set_schedulers_enabled(true);
12433        let (status, body) = body_of(state.clone()).await;
12434        assert_eq!(
12435            status,
12436            StatusCode::OK,
12437            "a watermark pause must not fail the liveness check: {body}"
12438        );
12439        assert!(body.contains("polling-paused: yes"), "{body}");
12440        // Schedulers on but no tick yet — and that must not read as "0s ago",
12441        // which is the healthiest possible answer to an unanswered question.
12442        assert!(
12443            body.contains("poller: not-yet-ticked"),
12444            "a never-ticked poller must say so: {body}"
12445        );
12446
12447        // A stale heartbeat is likewise reported, not fatal.
12448        let stale_after = health_tick_stale_secs(configured_poll_tick());
12449        let long_ago = chrono::Utc::now().timestamp() - (stale_after + 60);
12450        state.runtime_health.poll_tick_completed(long_ago);
12451        let (status, body) = body_of(state.clone()).await;
12452        assert_eq!(
12453            status,
12454            StatusCode::OK,
12455            "a stale poller must not 503: {body}"
12456        );
12457        assert!(body.contains("poller: stale"), "{body}");
12458
12459        // **A poller that has never ticked stops being benign.**
12460        //
12461        // In a crash loop with 30 s+ boot cycles the poller never reaches its
12462        // first tick, so `not-yet-ticked` was reported forever and the heartbeat
12463        // could not detect the one failure mode the startup delays were added
12464        // for. It is read against uptime now.
12465        state.runtime_health.poll_tick_completed(0); // reset to "never"
12466        state
12467            .runtime_health
12468            .set_started_at(chrono::Utc::now().timestamp() - (HEALTH_FIRST_TICK_GRACE_SECS + 60));
12469        let (status, body) = body_of(state.clone()).await;
12470        assert_eq!(status, StatusCode::OK);
12471        assert!(
12472            body.contains("poller: stale never-ticked"),
12473            "a poller that never ticked long after boot still reads as benign: {body}"
12474        );
12475
12476        // A closed pool is a real outage: nothing can be served, and a restart is
12477        // the correct response. THIS is what the status code is for.
12478        state.db.close().await;
12479        let (status, body) = body_of(state.clone()).await;
12480        assert_eq!(
12481            status,
12482            StatusCode::SERVICE_UNAVAILABLE,
12483            "an unreachable database must fail the check: {body}"
12484        );
12485        assert!(body.starts_with("FAIL"), "{body}");
12486        // Coarse, not the raw sqlx error: an unauthenticated caller learning
12487        // exactly which failure it hit is an attack-progress oracle, and this
12488        // endpoint is exempt from the origin lock.
12489        assert!(
12490            !body.contains("PoolClosed") && !body.contains("sqlx"),
12491            "health leaked the raw database error to an unauthenticated caller: {body}"
12492        );
12493    }
12494
12495    /// The staleness threshold must track the configured tick.
12496    ///
12497    /// Hardcoded at 15 minutes, an operator who raised
12498    /// `FEATHERREADER_POLL_TICK_SECS` above 900 got a permanent `poller: stale`
12499    /// in the body the deployment docs tell them to alert on.
12500    #[test]
12501    fn the_stale_threshold_follows_the_poll_tick() {
12502        // A fast tick keeps the floor — five 60 s ticks is 5 minutes, and
12503        // alerting that early would fire on any brief hiccup.
12504        assert_eq!(
12505            health_tick_stale_secs(Duration::from_secs(60)),
12506            HEALTH_TICK_STALE_FLOOR_SECS
12507        );
12508        // A slow tick raises it, so a legitimately-configured loop is never
12509        // permanently "stale".
12510        let slow = Duration::from_secs(30 * 60);
12511        assert!(
12512            health_tick_stale_secs(slow) > slow.as_secs() as i64,
12513            "a 30-minute tick must not be stale after one interval"
12514        );
12515        assert_eq!(health_tick_stale_secs(slow), 30 * 60 * 5);
12516        // And it cannot overflow into nonsense on an absurd value.
12517        assert!(health_tick_stale_secs(Duration::from_secs(u64::MAX)) > 0);
12518    }
12519
12520    /// `/stats` must distinguish "nothing is polling" from "polling is fine".
12521    ///
12522    /// `polling_paused` alone rendered "running" for three different states,
12523    /// including the two where nothing polls at all — on the page added to
12524    /// answer exactly that question.
12525    #[tokio::test]
12526    async fn stats_does_not_call_a_stopped_poller_running() {
12527        let state = test_state(&[]).await;
12528        let render = |state: AppState| async move {
12529            let resp = router(state)
12530                .oneshot(
12531                    Request::builder()
12532                        .uri("/stats")
12533                        .body(Body::empty())
12534                        .unwrap(),
12535                )
12536                .await
12537                .unwrap();
12538            assert_eq!(resp.status(), StatusCode::OK);
12539            String::from_utf8(
12540                axum::body::to_bytes(resp.into_body(), usize::MAX)
12541                    .await
12542                    .unwrap()
12543                    .to_vec(),
12544            )
12545            .unwrap()
12546        };
12547
12548        // Schedulers never started: not "running".
12549        let body = render(state.clone()).await;
12550        assert!(
12551            body.contains("the poller is not running on this instance"),
12552            "a disabled poller renders as healthy"
12553        );
12554
12555        // Started, but no tick has finished yet.
12556        state.runtime_health.set_schedulers_enabled(true);
12557        let body = render(state.clone()).await;
12558        assert!(
12559            body.contains("no poll has finished since this instance booted"),
12560            "a poller that has not ticked renders as healthy"
12561        );
12562
12563        // Ticking: running.
12564        state
12565            .runtime_health
12566            .poll_tick_completed(chrono::Utc::now().timestamp());
12567        let body = render(state.clone()).await;
12568        assert!(
12569            body.contains("running"),
12570            "a healthy poller must read as running"
12571        );
12572
12573        // Paused at the watermark still wins over "running".
12574        state.runtime_health.set_watermark(true);
12575        let body = render(state.clone()).await;
12576        assert!(
12577            body.contains("the cache is at its size limit"),
12578            "a watermark pause is hidden once the poller is ticking"
12579        );
12580    }
12581
12582    /// **An UNMEASURED database must not fail the check.**
12583    ///
12584    /// `/health` is the one path exempt from the Cloudflare origin lock and
12585    /// absent from the rate limiter, and `DbProbeGuard` releases its claim on
12586    /// drop WITHOUT recording a verdict — so a cancelled request (a client
12587    /// disconnect is enough) leaves the verdict at "none", and a concurrent
12588    /// caller reads it. Treating that as a failure turned an unauthenticated
12589    /// request into a lever on the only signal the platform acts on. The
12590    /// previous version of this code had the opposite bug and reported `ok` for
12591    /// a database nothing had read; "unknown" is neither.
12592    #[tokio::test]
12593    async fn health_reports_an_unmeasured_database_without_failing() {
12594        use crate::runtime_health::DbProbe;
12595        let state = test_state(&[]).await;
12596
12597        // Hold the probe claim, exactly as an in-flight request would, and never
12598        // record a verdict — the cancelled-request state.
12599        let held = state
12600            .runtime_health
12601            .begin_db_probe()
12602            .unwrap_or_else(|_| panic!("a fresh RuntimeHealth must grant the first claim"));
12603
12604        let resp = router(state.clone())
12605            .oneshot(
12606                Request::builder()
12607                    .uri("/health")
12608                    .body(Body::empty())
12609                    .unwrap(),
12610            )
12611            .await
12612            .unwrap();
12613        let status = resp.status();
12614        let body = String::from_utf8(
12615            axum::body::to_bytes(resp.into_body(), usize::MAX)
12616                .await
12617                .unwrap()
12618                .to_vec(),
12619        )
12620        .unwrap();
12621        drop(held);
12622
12623        assert_eq!(
12624            status,
12625            StatusCode::OK,
12626            "an unmeasured database failed the check, which an unauthenticated \
12627             caller can cause on demand: {body}"
12628        );
12629        assert!(
12630            body.contains("db: unknown"),
12631            "the unmeasured state must still be REPORTED: {body}"
12632        );
12633        assert!(!body.starts_with("FAIL"), "{body}");
12634        // **And it must not read as `ok` either.** `fly.toml` tells operators to
12635        // alert on the BODY for everything the status code ignores, so a first
12636        // line identical to the healthy one makes a monitor keying on `^ok` read
12637        // green in exactly the state this enum exists to surface.
12638        assert!(
12639            !body.starts_with("ok"),
12640            "the unmeasured state is indistinguishable from healthy to a \
12641             body-matching monitor: {body}"
12642        );
12643        assert!(body.starts_with("unknown"), "{body}");
12644
12645        // **A BORROWED failure must 503 too.**
12646        //
12647        // This previously recorded `Failed` and then closed the pool — but
12648        // `record` consumes the guard and releases the claim, so the request won
12649        // it, ran a live probe against the closed pool, and failed on its own.
12650        // The 503 passed for the wrong reason and the borrow path — the whole
12651        // point of the three-state enum on the read side — had no coverage.
12652        //
12653        // Holding the claim forces the borrow, so the recorded verdict is what
12654        // gets reported.
12655        let held = state
12656            .runtime_health
12657            .begin_db_probe()
12658            .unwrap_or_else(|_| panic!("claim"));
12659        state
12660            .runtime_health
12661            .record_for_test(DbProbe::Failed("unavailable".to_string()));
12662        let resp = router(state.clone())
12663            .oneshot(
12664                Request::builder()
12665                    .uri("/health")
12666                    .body(Body::empty())
12667                    .unwrap(),
12668            )
12669            .await
12670            .unwrap();
12671        let status = resp.status();
12672        let body = String::from_utf8(
12673            axum::body::to_bytes(resp.into_body(), usize::MAX)
12674                .await
12675                .unwrap()
12676                .to_vec(),
12677        )
12678        .unwrap();
12679        drop(held);
12680        assert_eq!(
12681            status,
12682            StatusCode::SERVICE_UNAVAILABLE,
12683            "a BORROWED failure verdict must fail the check, not just a freshly \
12684             measured one: {body}"
12685        );
12686        assert!(body.starts_with("FAIL"), "{body}");
12687
12688        state.db.close().await;
12689        let resp = router(state.clone())
12690            .oneshot(
12691                Request::builder()
12692                    .uri("/health")
12693                    .body(Body::empty())
12694                    .unwrap(),
12695            )
12696            .await
12697            .unwrap();
12698        assert_eq!(
12699            resp.status(),
12700            StatusCode::SERVICE_UNAVAILABLE,
12701            "a measured database failure must still fail the check"
12702        );
12703    }
12704
12705    /// **A disconnected client must not be able to cancel the probe.**
12706    ///
12707    /// Axum drops the handler future when a caller goes away. With the probe
12708    /// inline that dropped it mid-flight and released the claim WITHOUT
12709    /// recording a verdict — which let an unauthenticated caller manufacture the
12710    /// no-verdict state on demand and freeze what every other caller, including
12711    /// Fly's own check, reads. The probe runs detached now, so the verdict is
12712    /// recorded whatever happens to the request that started it.
12713    #[tokio::test]
12714    async fn an_abandoned_request_still_records_its_probe() {
12715        use crate::runtime_health::DbProbe;
12716        let state = test_state(&[]).await;
12717        let rh = state.runtime_health.clone();
12718
12719        // Drive /health and abandon it immediately — the disconnect case.
12720        let app = router(state.clone());
12721        let fut = app.oneshot(
12722            Request::builder()
12723                .uri("/health")
12724                .body(Body::empty())
12725                .unwrap(),
12726        );
12727        let handle = tokio::spawn(fut);
12728        handle.abort();
12729        let _ = handle.await;
12730
12731        // The detached probe still completes and publishes a verdict, so the
12732        // claim is free and the next caller gets a MEASURED answer.
12733        for _ in 0..50 {
12734            if rh.begin_db_probe().is_ok() {
12735                break;
12736            }
12737            tokio::time::sleep(Duration::from_millis(20)).await;
12738        }
12739        let resp = router(state.clone())
12740            .oneshot(
12741                Request::builder()
12742                    .uri("/health")
12743                    .body(Body::empty())
12744                    .unwrap(),
12745            )
12746            .await
12747            .unwrap();
12748        let body = String::from_utf8(
12749            axum::body::to_bytes(resp.into_body(), usize::MAX)
12750                .await
12751                .unwrap()
12752                .to_vec(),
12753        )
12754        .unwrap();
12755        assert!(
12756            body.contains("db: ok"),
12757            "after an abandoned request the next caller still reads an \
12758             unmeasured database — the probe was cancelled with it: {body}"
12759        );
12760        // Sanity: the type still distinguishes the three states.
12761        assert_ne!(DbProbe::Unknown, DbProbe::Ok);
12762    }
12763
12764    /// **The probe must read a real page.**
12765    ///
12766    /// `SELECT 1` compiles to `Init/Integer/ResultRow/Halt` — no `OpenRead`, so
12767    /// it never touches a b-tree and returns success against a corrupted
12768    /// database. Asserted by asking SQLite what the statement actually compiles
12769    /// to, so it survives someone "simplifying" the query later.
12770    #[tokio::test]
12771    async fn the_health_probe_opens_a_real_table() {
12772        use sqlx::Row;
12773        let state = test_state(&[]).await;
12774        // `EXPLAIN` lists the VM program; the `opcode` column is the second.
12775        let opcodes = |sql: &'static str| {
12776            let db = state.db.clone();
12777            async move {
12778                sqlx::query(sql)
12779                    .fetch_all(&db)
12780                    .await
12781                    .unwrap()
12782                    .into_iter()
12783                    .map(|r| r.get::<String, _>("opcode"))
12784                    .collect::<Vec<String>>()
12785            }
12786        };
12787
12788        // The statement `health_db_probe` really runs — it is the sole path, so
12789        // there is no second string for the handler to use instead.
12790        let explain: &'static str =
12791            Box::leak(format!("EXPLAIN {HEALTH_DB_PROBE_SQL}").into_boxed_str());
12792        let probe = opcodes(explain).await;
12793        // And the probe itself works against a real schema.
12794        assert!(
12795            health_db_probe(&state.db).await.is_ok(),
12796            "the probe does not run against the real schema",
12797        );
12798        assert!(
12799            probe.iter().any(|op| op == "OpenRead"),
12800            "the health probe reads no page; it cannot detect a broken database: {probe:?}"
12801        );
12802        // And the bare form genuinely does not, which is the whole point.
12803        let bare = opcodes("EXPLAIN SELECT 1").await;
12804        assert!(
12805            !bare.iter().any(|op| op == "OpenRead"),
12806            "premise check failed: bare SELECT 1 now reads a page: {bare:?}"
12807        );
12808    }
12809
12810    /// A fresh instance says "never", not "0" — which would read as "polled
12811    /// just now", the opposite of the truth.
12812    #[test]
12813    fn an_instance_that_has_never_polled_says_so() {
12814        assert_eq!(humanise_ago(None), "never");
12815        assert_eq!(humanise_ago(Some(0)), "0s ago");
12816        assert_eq!(humanise_ago(Some(59)), "59s ago");
12817        assert_eq!(humanise_ago(Some(60)), "1m ago");
12818        assert_eq!(humanise_ago(Some(3600)), "1h 0m ago");
12819        assert_eq!(humanise_ago(Some(11_460)), "3h 11m ago");
12820    }
12821
12822    /// A sidecar mock that answers `/internal/repo` listRecords with one saved
12823    /// record, and anything else with an empty list. Serves repeatedly.
12824    async fn spawn_saved_sidecar(saved_url: &str, saved_title: &str) -> String {
12825        use tokio::io::{AsyncReadExt, AsyncWriteExt};
12826        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
12827        let addr = listener.local_addr().unwrap();
12828        let (url, title) = (saved_url.to_string(), saved_title.to_string());
12829        tokio::spawn(async move {
12830            loop {
12831                let Ok((mut sock, _)) = listener.accept().await else {
12832                    break;
12833                };
12834                let mut buf = vec![0u8; 8192];
12835                let Ok(n) = sock.read(&mut buf).await else {
12836                    continue;
12837                };
12838                let req = String::from_utf8_lossy(&buf[..n]).to_string();
12839                let wants_saved = req.contains("community.lexicon.rss.saved");
12840                let records = if wants_saved {
12841                    serde_json::json!([{
12842                        "uri": "at://did:plc:x/community.lexicon.rss.saved/rk1",
12843                        "cid": "bafy",
12844                        "value": {
12845                            "$type": "community.lexicon.rss.saved",
12846                            "url": url,
12847                            "title": title,
12848                            "createdAt": "2026-01-01T00:00:00Z"
12849                        }
12850                    }])
12851                } else {
12852                    serde_json::json!([])
12853                };
12854                let body = serde_json::json!({
12855                    "ok": true, "data": { "records": records }
12856                })
12857                .to_string();
12858                let resp = format!(
12859                    "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
12860                    body.len(), body
12861                );
12862                let _ = sock.write_all(resp.as_bytes()).await;
12863                let _ = sock.flush().await;
12864            }
12865        });
12866        format!("http://{addr}")
12867    }
12868
12869    /// A sidecar mock serving `n` distinct saved records, none of them cached
12870    /// locally — the shape that exercises the uncached-row append.
12871    async fn spawn_saved_sidecar_many(n: usize, subscribed_feed: &str) -> String {
12872        let feed = subscribed_feed.to_string();
12873        use tokio::io::{AsyncReadExt, AsyncWriteExt};
12874        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
12875        let addr = listener.local_addr().unwrap();
12876        tokio::spawn(async move {
12877            loop {
12878                let Ok((mut sock, _)) = listener.accept().await else {
12879                    break;
12880                };
12881                let mut buf = vec![0u8; 8192];
12882                let Ok(read) = sock.read(&mut buf).await else {
12883                    continue;
12884                };
12885                let req = String::from_utf8_lossy(&buf[..read]).to_string();
12886                let records = if req.contains("community.lexicon.rss.saved") {
12887                    serde_json::Value::Array(
12888                        (0..n)
12889                            .map(|i| {
12890                                serde_json::json!({
12891                                    "uri": format!("at://did:plc:x/community.lexicon.rss.saved/rk{i}"),
12892                                    "cid": "bafy",
12893                                    "value": {
12894                                        "$type": "community.lexicon.rss.saved",
12895                                        "url": format!("https://elsewhere.example/{i}"),
12896                                        "title": format!("Elsewhere {i}"),
12897                                        "createdAt": "2026-01-01T00:00:00Z"
12898                                    }
12899                                })
12900                            })
12901                            .collect(),
12902                    )
12903                } else if req.contains("community.lexicon.rss.subscription") {
12904                    // Without this the handler's `sync_sub_refs` would REPLACE
12905                    // sub_ref with an empty set on every render, and every
12906                    // sub_ref-scoped read — including the cached starred list
12907                    // this test is about — would come back empty.
12908                    serde_json::json!([{
12909                        "uri": "at://did:plc:x/community.lexicon.rss.subscription/sub1",
12910                        "cid": "bafy",
12911                        "value": {
12912                            "$type": "community.lexicon.rss.subscription",
12913                            "url": feed,
12914                            "createdAt": "2026-01-01T00:00:00Z"
12915                        }
12916                    }])
12917                } else {
12918                    serde_json::json!([])
12919                };
12920                let body =
12921                    serde_json::json!({ "ok": true, "data": { "records": records } }).to_string();
12922                let resp = format!(
12923                    "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
12924                    body.len(), body
12925                );
12926                let _ = sock.write_all(resp.as_bytes()).await;
12927                let _ = sock.flush().await;
12928            }
12929        });
12930        format!("http://{addr}")
12931    }
12932
12933    /// **The pager must not advertise a page the clamp cannot reach.**
12934    ///
12935    /// The page clamp is computed from the CACHED total; the uncached PDS rows
12936    /// are appended to the last page rather than paged. Inflating `total` with
12937    /// them made `page_count` and the "Older →" link point one page past the end:
12938    /// requesting it clamped straight back, re-rendered the same last page, and
12939    /// still offered the link. An infinite "next" that never advances.
12940    #[tokio::test]
12941    async fn the_starred_pager_does_not_advertise_an_unreachable_page() {
12942        let did = "did:plc:pagerloop";
12943        let sidecar = spawn_saved_sidecar_many(80, "https://loop.example/feed.xml").await;
12944        let state = test_state_with_sidecar(&[], &sidecar).await;
12945        store::grant_access(&state.db, did, None, "test", None)
12946            .await
12947            .unwrap();
12948        let feed = store::upsert_feed(
12949            &state.db,
12950            &store::NewFeed {
12951                url: "https://loop.example/feed.xml".to_string(),
12952                title: Some("Loop".to_string()),
12953                ..Default::default()
12954            },
12955        )
12956        .await
12957        .unwrap();
12958        // 250 cached starred entries: the last page holds 50, so 50 + 80 > 100
12959        // and the old arithmetic reported a fourth page.
12960        let entries: Vec<store::NewEntry> = (0..250)
12961            .map(|i| store::NewEntry {
12962                guid: format!("s-{i:04}"),
12963                url: Some(format!("https://loop.example/{i}")),
12964                title: Some(format!("Starred {i:04}")),
12965                published: Some(format!("2026-06-{:02}T00:00:00Z", (i % 28) + 1)),
12966                ..Default::default()
12967            })
12968            .collect();
12969        store::insert_entries(&state.db, feed, &entries, 0)
12970            .await
12971            .unwrap();
12972        store::replace_sub_refs(&state.db, did, &[feed])
12973            .await
12974            .unwrap();
12975        for row in store::list_entries(&state.db, did, store::ListView::All, None, 1_000, 0)
12976            .await
12977            .unwrap()
12978        {
12979            store::mark_starred(&state.db, did, row.id, true)
12980                .await
12981                .unwrap();
12982        }
12983
12984        let cookie = session_cookie(&state, did, None);
12985        let app = router(state.clone());
12986        let get = |uri: &str| {
12987            let (app, cookie, uri) = (app.clone(), cookie.clone(), uri.to_string());
12988            async move {
12989                let resp = app
12990                    .oneshot(
12991                        Request::builder()
12992                            .uri(uri)
12993                            .header(header::COOKIE, cookie)
12994                            .body(Body::empty())
12995                            .unwrap(),
12996                    )
12997                    .await
12998                    .unwrap();
12999                assert_eq!(resp.status(), StatusCode::OK);
13000                String::from_utf8(
13001                    axum::body::to_bytes(resp.into_body(), 16 * 1024 * 1024)
13002                        .await
13003                        .unwrap()
13004                        .to_vec(),
13005                )
13006                .unwrap()
13007            }
13008        };
13009
13010        // 250 cached + 80 uncached = 330 rows over 4 pages. The pager and the
13011        // clamp must agree on that, and EVERY page it offers must have content —
13012        // the original bug advertised a fourth page that clamped back to the
13013        // third and re-rendered it, still offering the link.
13014        let p3 = get("/?view=starred&page=3").await;
13015        assert!(
13016            p3.contains("Page 3 of 4"),
13017            "the pager and the clamp disagree on the total: {}",
13018            p3.split("pager-pos")
13019                .nth(1)
13020                .unwrap_or("")
13021                .chars()
13022                .take(120)
13023                .collect::<String>()
13024        );
13025        // Page 3 is the boundary: the last 50 cached rows, then the first 50
13026        // uncached ones.
13027        assert!(
13028            p3.contains("Elsewhere 0"),
13029            "page 3 should start the uncached run"
13030        );
13031        assert_eq!(
13032            p3.matches("<li class=\"entry").count(),
13033            ENTRIES_PER_PAGE as usize,
13034            "the boundary page is not full"
13035        );
13036
13037        // **The heading, which the previous round broke by deleting this.**
13038        //
13039        // `total` includes the uncached records, so the parenthetical is a
13040        // SUBSET of it, not an addition — "330 entries (80 saved elsewhere)".
13041        // The version that said "plus N" double counted once `total` started
13042        // including them, and N had become page-local in the same commit while
13043        // the template stayed put. It shipped because this assertion was deleted
13044        // rather than updated.
13045        {
13046            let body = &p3;
13047            assert!(
13048                body.contains("330 entries"),
13049                "the heading must count the whole sequence: {}",
13050                body.split("content-count")
13051                    .nth(1)
13052                    .unwrap_or("")
13053                    .chars()
13054                    .take(120)
13055                    .collect::<String>()
13056            );
13057            assert!(
13058                body.contains("(80 saved elsewhere)"),
13059                "the heading must say how many of the total the cache cannot show, \
13060                 as a whole-list figure and not a per-page one: {}",
13061                body.split("content-count")
13062                    .nth(1)
13063                    .unwrap_or("")
13064                    .chars()
13065                    .take(120)
13066                    .collect::<String>()
13067            );
13068            assert!(
13069                !body.contains("plus 50") && !body.contains("plus 80"),
13070                "the heading is adding the uncached rows to a total that already \
13071                 includes them"
13072            );
13073        }
13074
13075        let p4 = get("/?view=starred&page=4").await;
13076        assert!(
13077            p4.contains("Page 4 of 4"),
13078            "page 4 was advertised but clamps somewhere else — the unreachable-page bug"
13079        );
13080        assert_eq!(
13081            p4.matches("<li class=\"entry").count(),
13082            30,
13083            "page 4 should hold the remaining 30 uncached records"
13084        );
13085        assert!(
13086            p4.contains("Elsewhere 79"),
13087            "the LAST saved record is unreachable — it can only be removed from here"
13088        );
13089
13090        // No uncached record appears on two pages.
13091        assert!(
13092            !p4.contains("Elsewhere 0"),
13093            "an uncached record was rendered on more than one page"
13094        );
13095        // Page 1 is all cached — and still reports the same whole-list heading,
13096        // because the parenthetical describes the LIST, not the page.
13097        let first = get("/?view=starred").await;
13098        assert!(
13099            first.contains("330 entries") && first.contains("(80 saved elsewhere)"),
13100            "the heading changed between pages; it describes the list, not the page"
13101        );
13102        assert!(
13103            !first.contains("Elsewhere "),
13104            "uncached saved records leaked onto the first page"
13105        );
13106    }
13107
13108    /// **A saved record whose article is not cached here is still shown.**
13109    ///
13110    /// The starred view is built from local `entries`, so before this a record
13111    /// starred in ANOTHER atproto reader — the portability the shared lexicon
13112    /// exists for — was simply invisible. It now renders from the PDS record,
13113    /// visually distinct, linking straight out.
13114    #[tokio::test]
13115    async fn a_saved_record_with_no_cached_entry_is_shown_as_a_link() {
13116        let did = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
13117        let sidecar =
13118            spawn_saved_sidecar("https://elsewhere.example/article", "Starred elsewhere").await;
13119        let mut state = test_state_with_sidecar(&[did], &sidecar).await;
13120        std::sync::Arc::get_mut(&mut state.config).unwrap().dev_did = Some(did.to_string());
13121
13122        let resp = router(state)
13123            .oneshot(
13124                Request::builder()
13125                    .uri("/?view=starred")
13126                    .body(Body::empty())
13127                    .unwrap(),
13128            )
13129            .await
13130            .unwrap();
13131        assert_eq!(resp.status(), StatusCode::OK);
13132        let body = String::from_utf8(
13133            axum::body::to_bytes(resp.into_body(), usize::MAX)
13134                .await
13135                .unwrap()
13136                .to_vec(),
13137        )
13138        .unwrap();
13139
13140        assert!(
13141            body.contains("Starred elsewhere"),
13142            "the saved record was not rendered at all"
13143        );
13144        assert!(
13145            body.contains("entry-uncached"),
13146            "it was not marked as uncached, so it looks like a normal entry"
13147        );
13148        assert!(
13149            body.contains("https://elsewhere.example/article"),
13150            "the row must link straight to the article"
13151        );
13152        assert!(
13153            !body.contains("/entries/0/"),
13154            "an uncached row must not offer entry actions against a nonexistent id"
13155        );
13156    }
13157
13158    /// **A PDS `createdAt` must not be able to panic the starred view.**
13159    ///
13160    /// `display_date` byte-sliced `p[..10]`. Every prior caller passed a
13161    /// timestamp the feed parser produced; the saved-record path passes a bare
13162    /// string off a PDS record, written by whatever client the reader used. A
13163    /// multi-byte value panicked the handler, and with no catch-panic layer the
13164    /// view stayed down until the record was removed — from that same view.
13165    #[test]
13166    fn a_multibyte_timestamp_does_not_panic_the_date_formatter() {
13167        for hostile in [
13168            "日本語日本語日本",
13169            "é",
13170            "",
13171            "2026",
13172            "🙂🙂🙂🙂🙂🙂🙂🙂🙂🙂🙂",
13173        ] {
13174            let out = display_date(Some(hostile));
13175            assert!(out.chars().count() <= 10, "{hostile:?} -> {out:?}");
13176        }
13177        assert_eq!(display_date(Some("2026-01-01T00:00:00Z")), "2026-01-01");
13178        assert_eq!(display_date(None), "");
13179    }
13180
13181    /// Unsaving makes a DPoP-signed PDS round-trip, which is the stated reason
13182    /// its neighbours are limited. It was added as a route and not added here.
13183    #[test]
13184    fn the_unsave_route_is_rate_limited() {
13185        use axum::http::Method;
13186        assert!(is_rate_limited_path("/saved/3abc/delete", &Method::POST));
13187        // And the neighbours still are.
13188        assert!(is_rate_limited_path("/entries/1/star", &Method::POST));
13189    }
13190
13191    /// **The probe detects a broken database — asserted through `/health`
13192    /// itself, not through a string.**
13193    ///
13194    /// A named constant did not bind the handler: it stayed free to call
13195    /// `query_scalar` with a different literal, so degrading the real probe to
13196    /// `SELECT 1` shipped green twice over. This drops the table the probe reads
13197    /// and asserts the endpoint stops saying `ok` — behaviour no substituted SQL
13198    /// can fake, because `SELECT 1` still succeeds against a wrecked schema.
13199    #[tokio::test]
13200    async fn health_reports_a_broken_database() {
13201        let state = test_state(&[]).await;
13202        // Sanity: healthy first, so the assertion below is about the damage.
13203        assert!(
13204            health_db_probe(&state.db).await.is_ok(),
13205            "the fixture was not healthy to begin with",
13206        );
13207
13208        sqlx::query("DROP TABLE feeds")
13209            .execute(&state.db)
13210            .await
13211            .unwrap();
13212
13213        assert!(
13214            health_db_probe(&state.db).await.is_err(),
13215            "the probe reported success against a database missing the table it \
13216             claims to read; `SELECT 1` would do exactly this",
13217        );
13218
13219        let resp = router(state)
13220            .oneshot(
13221                Request::builder()
13222                    .uri("/health")
13223                    .body(Body::empty())
13224                    .unwrap(),
13225            )
13226            .await
13227            .unwrap();
13228        let body = String::from_utf8(
13229            axum::body::to_bytes(resp.into_body(), usize::MAX)
13230                .await
13231                .unwrap()
13232                .to_vec(),
13233        )
13234        .unwrap();
13235        // The documented contract: the FIRST token is the state.
13236        assert!(
13237            body.starts_with("FAIL"),
13238            "/health did not report FAIL for a broken database: {body}",
13239        );
13240        assert!(
13241            !body.contains("db: ok"),
13242            "/health still called the database ok: {body}",
13243        );
13244    }
13245
13246    /// A sidecar mock for the OPML export: serves one subscription and one
13247    /// folder, except for the collection named in `fail_on`, which answers
13248    /// `500` — the shape a refused (short or unreadable) walk takes at this
13249    /// boundary.
13250    async fn spawn_export_sidecar(fail_on: Option<&'static str>) -> String {
13251        use tokio::io::{AsyncReadExt, AsyncWriteExt};
13252        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
13253        let addr = listener.local_addr().unwrap();
13254        tokio::spawn(async move {
13255            loop {
13256                let Ok((mut sock, _)) = listener.accept().await else {
13257                    break;
13258                };
13259                let mut buf = vec![0u8; 8192];
13260                let Ok(n) = sock.read(&mut buf).await else {
13261                    continue;
13262                };
13263                let req = String::from_utf8_lossy(&buf[..n]).to_string();
13264                let wants = |c: &str| req.contains(c);
13265                if fail_on.is_some_and(wants) {
13266                    let body = r#"{"ok":false,"error":"ShortList"}"#;
13267                    let resp = format!(
13268                        "HTTP/1.1 500 Internal Server Error\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
13269                        body.len(),
13270                        body
13271                    );
13272                    let _ = sock.write_all(resp.as_bytes()).await;
13273                    let _ = sock.flush().await;
13274                    continue;
13275                }
13276                let records = if wants(crate::lexicon::nsid::SUBSCRIPTION) {
13277                    serde_json::json!([{
13278                        "uri": "at://did:plc:exporter/community.lexicon.rss.subscription/sub1",
13279                        "cid": "bafy",
13280                        "value": {
13281                            "$type": crate::lexicon::nsid::SUBSCRIPTION,
13282                            "url": "https://kept.example/feed.xml",
13283                            "title": "Kept",
13284                            // Inside the folder, so the healthy export has to
13285                            // carry BOTH walks' results: an exporter that lost
13286                            // the folder list would flatten this outline out of
13287                            // its group with nothing else changing.
13288                            "folder": "at://did:plc:exporter/community.lexicon.rss.folder/fold1",
13289                            "createdAt": "2026-01-01T00:00:00Z"
13290                        }
13291                    }])
13292                } else if wants(crate::lexicon::nsid::FOLDER) {
13293                    serde_json::json!([{
13294                        "uri": "at://did:plc:exporter/community.lexicon.rss.folder/fold1",
13295                        "cid": "bafy",
13296                        "value": {
13297                            "$type": crate::lexicon::nsid::FOLDER,
13298                            "name": "Kept folder",
13299                            "createdAt": "2026-01-01T00:00:00Z"
13300                        }
13301                    }])
13302                } else {
13303                    serde_json::json!([])
13304                };
13305                let body =
13306                    serde_json::json!({ "ok": true, "data": { "records": records } }).to_string();
13307                let resp = format!(
13308                    "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
13309                    body.len(),
13310                    body
13311                );
13312                let _ = sock.write_all(resp.as_bytes()).await;
13313                let _ = sock.flush().await;
13314            }
13315        });
13316        format!("http://{addr}")
13317    }
13318
13319    /// A sidecar whose every `listRecords` page carries one good record and
13320    /// one with no `uri` — the #177 shape — for any collection.
13321    async fn spawn_malformed_sidecar() -> String {
13322        use tokio::io::{AsyncReadExt, AsyncWriteExt};
13323        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
13324        let addr = listener.local_addr().unwrap();
13325        tokio::spawn(async move {
13326            loop {
13327                let Ok((mut sock, _)) = listener.accept().await else {
13328                    break;
13329                };
13330                let mut buf = vec![0u8; 8192];
13331                let _ = sock.read(&mut buf).await;
13332                let body = serde_json::json!({ "ok": true, "data": { "records": [
13333                    { "uri": "at://did:plc:alerted/c/3labGOOD", "cid": "bafy", "value": {} },
13334                    { "cid": "bafy", "value": {} },
13335                ]}})
13336                .to_string();
13337                let resp = format!(
13338                    "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
13339                    body.len(),
13340                    body
13341                );
13342                let _ = sock.write_all(resp.as_bytes()).await;
13343                let _ = sock.flush().await;
13344            }
13345        });
13346        format!("http://{addr}")
13347    }
13348
13349    async fn page_body(state: AppState, did: &str, uri: &str) -> (StatusCode, String) {
13350        let cookie = session_cookie(&state, did, None);
13351        let resp = router(state)
13352            .oneshot(
13353                Request::builder()
13354                    .uri(uri)
13355                    .header(header::COOKIE, cookie)
13356                    .body(Body::empty())
13357                    .unwrap(),
13358            )
13359            .await
13360            .unwrap();
13361        let status = resp.status();
13362        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
13363            .await
13364            .unwrap();
13365        (status, String::from_utf8_lossy(&body).to_string())
13366    }
13367
13368    /// **0.4.0 step 4: a publication document with neither summary field
13369    /// renders as a title, a date and a link** — 8% of measured documents
13370    /// (37 of 449) carry neither `description` nor `textContent`. That is what
13371    /// an RSS reader shows for a title-only feed, not an error, in the list and
13372    /// on the article page alike.
13373    #[tokio::test]
13374    async fn a_publication_entry_with_no_summary_renders_title_date_and_link() {
13375        let did = "did:plc:displayer";
13376        let state = test_state(&[did]).await;
13377        let url = "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lab";
13378        let feed_id = store::upsert_feed(
13379            &state.db,
13380            &store::NewFeed {
13381                url: url.into(),
13382                title: Some("Quiet Journal".into()),
13383                ..Default::default()
13384            },
13385        )
13386        .await
13387        .unwrap();
13388        store::replace_sub_refs(&state.db, did, &[feed_id])
13389            .await
13390            .unwrap();
13391        store::insert_entries(
13392            &state.db,
13393            feed_id,
13394            &[store::NewEntry {
13395                guid: "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.document/3l2nosumaaa2a"
13396                    .into(),
13397                url: Some("https://quiet.example/no-summary".into()),
13398                title: Some("A title-only article".into()),
13399                published: Some("2026-07-11T00:00:00Z".into()),
13400                content_html: None,
13401                ..Default::default()
13402            }],
13403            0,
13404        )
13405        .await
13406        .unwrap();
13407        let (status, list) = page_body(state.clone(), did, "/?view=all").await;
13408        assert_eq!(status, StatusCode::OK);
13409        assert!(
13410            list.contains("A title-only article"),
13411            "the entry is missing from the list"
13412        );
13413
13414        let id: i64 = sqlx::query_scalar("SELECT id FROM entries WHERE feed_id = ?")
13415            .bind(feed_id)
13416            .fetch_one(&state.db)
13417            .await
13418            .unwrap();
13419        let (status, page) = page_body(state, did, &format!("/entries/{id}")).await;
13420        assert_eq!(
13421            status,
13422            StatusCode::OK,
13423            "the article page failed for an entry with no body"
13424        );
13425        assert!(page.contains("A title-only article"));
13426        assert!(
13427            page.contains("https://quiet.example/no-summary"),
13428            "no link to the original"
13429        );
13430        assert!(
13431            page.contains(r#"<time datetime=""#),
13432            "no date on the article page"
13433        );
13434    }
13435
13436    /// **#177: a malformed record in the reader's own repo is refused, and the
13437    /// reader is told.** Refusing keeps `replace_sub_refs` from dropping the
13438    /// subscription that record was; telling them keeps the stale list from
13439    /// looking like the real one. Both the reading page and the manage page.
13440    #[tokio::test]
13441    async fn a_malformed_subscription_record_raises_an_alert() {
13442        let did = "did:plc:alerted";
13443        for page in ["/", "/manage"] {
13444            let sidecar = spawn_malformed_sidecar().await;
13445            let state = test_state_with_sidecar(&[did], &sidecar).await;
13446            let (status, body) = page_body(state, did, page).await;
13447            assert_eq!(status, StatusCode::OK, "{page} did not render");
13448            assert!(
13449                body.contains(r#"role="alert""#) && body.contains("could not be read"),
13450                "{page} rendered no alert for a refused subscription list"
13451            );
13452            assert!(
13453                body.contains("1 record(s) in your subscription list"),
13454                "{page} gave the generic alert, not the malformed-record one"
13455            );
13456        }
13457    }
13458
13459    /// The control: a healthy listing raises no alert.
13460    #[tokio::test]
13461    async fn a_healthy_subscription_listing_raises_no_alert() {
13462        let did = "did:plc:exporter";
13463        let sidecar = spawn_export_sidecar(None).await;
13464        let state = test_state_with_sidecar(&[did], &sidecar).await;
13465        let (status, body) = page_body(state, did, "/").await;
13466        assert_eq!(status, StatusCode::OK);
13467        assert!(
13468            !body.contains("could not be read"),
13469            "a healthy listing raised an alert"
13470        );
13471    }
13472
13473    /// `GET /opml/export` against the mock, returning `(status, headers, body)`.
13474    async fn export_opml_response(
13475        fail_on: Option<&'static str>,
13476    ) -> (StatusCode, HeaderMap, String) {
13477        let did = "did:plc:exporter";
13478        let sidecar = spawn_export_sidecar(fail_on).await;
13479        let state = test_state_with_sidecar(&[did], &sidecar).await;
13480        let cookie = session_cookie(&state, did, None);
13481        let resp = router(state)
13482            .oneshot(
13483                Request::builder()
13484                    .uri("/opml/export")
13485                    .header(header::COOKIE, cookie)
13486                    .body(Body::empty())
13487                    .unwrap(),
13488            )
13489            .await
13490            .unwrap();
13491        let status = resp.status();
13492        let headers = resp.headers().clone();
13493        let body = String::from_utf8_lossy(
13494            &axum::body::to_bytes(resp.into_body(), usize::MAX)
13495                .await
13496                .unwrap(),
13497        )
13498        .to_string();
13499        (status, headers, body)
13500    }
13501
13502    /// **An empty export is worse than no export, and this is the caller that
13503    /// used to produce one.**
13504    ///
13505    /// `export_opml` read both walks through `unwrap_or_default()`. Now that a
13506    /// truncated walk refuses instead of returning a short list, that turned the
13507    /// refusal into `200 OK` carrying a zero-feed
13508    /// `featherreader-subscriptions.opml` — a blank backup handed over at exactly
13509    /// the moment a locked-out reader reached for one, and the changelog points
13510    /// them at this route as the recovery path.
13511    ///
13512    /// Asserts the three things a reader can actually observe: no success status,
13513    /// no download offered, and no OPML document in the body.
13514    #[tokio::test]
13515    async fn an_export_that_cannot_read_the_subscriptions_serves_no_opml() {
13516        let (status, headers, body) =
13517            export_opml_response(Some(crate::lexicon::nsid::SUBSCRIPTION)).await;
13518
13519        assert_ne!(
13520            status,
13521            StatusCode::OK,
13522            "a failed subscription walk answered 200: {body}",
13523        );
13524        assert!(
13525            !headers.contains_key(header::CONTENT_DISPOSITION),
13526            "a failed subscription walk still offered a download: {headers:?}",
13527        );
13528        assert!(
13529            !body.contains("<opml"),
13530            "a failed subscription walk still served an OPML document: {body}",
13531        );
13532    }
13533
13534    /// The folders half of the same hole. The two walks are separate calls, and
13535    /// fixing only the first leaves an export that silently loses every folder —
13536    /// a flat list that reimports as one, with no sign anything was lost.
13537    #[tokio::test]
13538    async fn an_export_that_cannot_read_the_folders_serves_no_opml() {
13539        let (status, headers, body) =
13540            export_opml_response(Some(crate::lexicon::nsid::FOLDER)).await;
13541
13542        assert_ne!(
13543            status,
13544            StatusCode::OK,
13545            "a failed folder walk answered 200: {body}",
13546        );
13547        assert!(
13548            !headers.contains_key(header::CONTENT_DISPOSITION),
13549            "a failed folder walk still offered a download: {headers:?}",
13550        );
13551        assert!(
13552            !body.contains("<opml"),
13553            "a failed folder walk still served an OPML document: {body}",
13554        );
13555    }
13556
13557    /// The other direction, without which "refuse everything" would pass both
13558    /// tests above: a healthy read still serves the file, with the feed in it.
13559    #[tokio::test]
13560    async fn a_healthy_export_serves_the_subscriptions_as_a_download() {
13561        let (status, headers, body) = export_opml_response(None).await;
13562
13563        assert_eq!(
13564            status,
13565            StatusCode::OK,
13566            "a healthy export did not answer 200"
13567        );
13568        assert_eq!(
13569            headers
13570                .get(header::CONTENT_DISPOSITION)
13571                .and_then(|v| v.to_str().ok()),
13572            Some("attachment; filename=\"featherreader-subscriptions.opml\""),
13573            "a healthy export did not offer the download",
13574        );
13575        assert!(
13576            body.contains("https://kept.example/feed.xml"),
13577            "the exported OPML lost the subscription: {body}",
13578        );
13579        assert!(
13580            body.contains("Kept folder"),
13581            "the exported OPML lost the folder: {body}",
13582        );
13583    }
13584}