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.4",
1568        date: "2026-10-05",
1569        summary: "The feed parser moves to feed-rs 3.0 with entry ids and \
1570                  links unchanged and real RSS bylines, and the address guard \
1571                  refuses the reserved ranges it missed.",
1572    },
1573    Release {
1574        version: "0.4.3",
1575        date: "2026-10-05",
1576        summary: "Two write-path fixes for any PDS: large OPML imports and \
1577                  read-state syncs are sent in calls the PDS accepts, and a \
1578                  read-state sync that disagreed with the PDS recovers instead \
1579                  of failing every round.",
1580    },
1581    Release {
1582        version: "0.4.2",
1583        date: "2026-10-04",
1584        summary: "A public standard.site feature page with this list of recent \
1585                  releases, and link cards: a posted feather-reader.com link \
1586                  now unfurls with a description and an image.",
1587    },
1588    Release {
1589        version: "0.4.1",
1590        date: "2026-10-04",
1591        summary: "The public pages explain standard.site publications, and the \
1592                  subscribe form can submit the DID form of a publication URI, \
1593                  which browsers refused in 0.4.0.",
1594    },
1595    Release {
1596        version: "0.4.0",
1597        date: "2026-10-03",
1598        summary: "standard.site support: publications are read from their \
1599                  authors' atproto repos as subscriptions, beside RSS, on their \
1600                  own polling loop. Every stored field from a feed or a \
1601                  publication now has a size bound.",
1602    },
1603];
1604
1605/// The public `/stats` page — is the poller keeping up?
1606///
1607/// Aggregate only, deliberately. It is published to anyone, so it carries no
1608/// user counts and no per-feed detail: a reader does not need to know how many
1609/// people use an instance or which feeds are failing. What it does answer is the
1610/// question that decides whether an instance can take more readers — whether the
1611/// poller is servicing the feeds it already has.
1612///
1613/// The counts below are aggregate machine facts, which is why they fit that
1614/// contract: "12 feeds are in backoff" names no feed and no reader, while
1615/// answering the question the page was previously unable to answer at all.
1616#[derive(Template)]
1617#[template(path = "stats.html")]
1618struct StatsTemplate {
1619    /// The link card: this page's own title and description.
1620    card: Card,
1621    version: &'static str,
1622    repo_url: &'static str,
1623    kofi_url: &'static str,
1624    feeds_tracked: i64,
1625    polled_last_hour: i64,
1626    polled_pct: i64,
1627    overdue: i64,
1628    last_poll: String,
1629    oldest_poll: String,
1630    never_polled: i64,
1631    poll_interval_mins: i64,
1632    /// Feeds in error backoff. Invisible before, and excluded from `overdue`.
1633    in_backoff: i64,
1634    /// Of those, the ones retried hours apart rather than minutes. **Not
1635    /// "effectively dead"** — see `store::BADLY_BROKEN_ERRORS`; they recover on
1636    /// their next successful poll, and most of this instance's did.
1637    badly_broken: i64,
1638    /// Failing feeds by cause, descending — counts only, never which feed.
1639    failure_kinds: Vec<(String, i64)>,
1640    /// What the poller is actually doing: `running`, `paused` (at the size
1641    /// watermark), `starting` (no tick completed yet) or `off` (schedulers
1642    /// disabled). Three of those four used to render as "running".
1643    fetching: &'static str,
1644}
1645
1646/// The public `/privacy` page — what the server holds vs. what lives in the
1647/// user's PDS. Carries the same `repo_url`/`kofi_url`/`version` the shared
1648/// footer include needs.
1649#[derive(Template)]
1650#[template(path = "privacy.html")]
1651struct PrivacyTemplate {
1652    /// The link card: this page's own title and description.
1653    card: Card,
1654    version: &'static str,
1655    repo_url: &'static str,
1656    kofi_url: &'static str,
1657}
1658
1659/// The public `/terms` page — the as-is / no-warranty terms of use. Carries the
1660/// same fields the shared footer include needs.
1661#[derive(Template)]
1662#[template(path = "terms.html")]
1663struct TermsTemplate {
1664    /// The link card: this page's own title and description.
1665    card: Card,
1666    version: &'static str,
1667    repo_url: &'static str,
1668    kofi_url: &'static str,
1669}
1670
1671/// The signed-out landing page (`GET /` with no session) — the public front
1672/// door at feather-reader.com. A static render, no session required.
1673#[derive(Template)]
1674#[template(path = "landing.html")]
1675struct LandingTemplate {
1676    /// The link card: the site's own title and description.
1677    card: Card,
1678    version: &'static str,
1679    repo_url: &'static str,
1680    crates_url: &'static str,
1681    kofi_url: &'static str,
1682    /// `Config::standard_site`: whether the publications point may tell a
1683    /// visitor how to subscribe to one here. See [`ManageTemplate::standard_site`].
1684    standard_site: bool,
1685    /// [`RELEASES`], newest first, for the quiet "latest releases" strip.
1686    releases: &'static [Release],
1687}
1688
1689/// The single-entry reader view (`GET /entries/:id`).
1690#[derive(Template)]
1691#[template(path = "entry.html")]
1692struct EntryTemplate {
1693    /// The link card. A private view: the site's generic card, `noindex`.
1694    card: Card,
1695    version: &'static str,
1696    repo_url: &'static str,
1697    kofi_url: &'static str,
1698    nav: Nav,
1699    id: i64,
1700    title: String,
1701    feed_title: String,
1702    author: Option<String>,
1703    published: String,
1704    /// The entry's own link, for `entry.html`'s two `href`s.
1705    ///
1706    /// `Option<SafeLink>`, not `Option<String>`: the column it comes from holds
1707    /// a remote feed's `<link>`. Ingest scheme-checks it, but that guard is a
1708    /// long way from the `href` and holds only while every future writer to
1709    /// `entries.url` remembers to go through `feed.rs` — the same procedural
1710    /// defence that, on the saved-record row, turned out to be deletable with
1711    /// all 679 tests still green. `None` is the refusal: the template's
1712    /// no-URL branch already renders a disabled open-original button.
1713    url: Option<SafeLink>,
1714    content_html: Option<String>,
1715    read: bool,
1716    starred: bool,
1717    /// The query string to carry the reading context back to the list.
1718    back_qs: String,
1719    /// Prev/next entry ids within the current list, for keyboard/paging nav.
1720    prev_id: Option<i64>,
1721    next_id: Option<i64>,
1722    /// Rendered inline (not an out-of-band swap fragment): always `false` here.
1723    oob: bool,
1724}
1725
1726/// The htmx swap fragment for a single entry row (`entry_row.html`).
1727#[derive(Template)]
1728#[template(path = "entry_row.html")]
1729struct EntryRowTemplate {
1730    e: EntryRow,
1731}
1732
1733/// The reader's action-bar fragment (`entry_actionbar.html`) returned as an
1734/// out-of-band swap after a mark-read / star toggle FROM THE READER, so the
1735/// button's hidden value + `aria-pressed` update in place (the reader `<li>`
1736/// isn't in the DOM to swap, unlike the list view's `entry_row.html`).
1737#[derive(Template)]
1738#[template(path = "entry_actionbar.html")]
1739struct EntryActionBarTemplate {
1740    id: i64,
1741    read: bool,
1742    starred: bool,
1743    /// Emit the `hx-swap-oob` attribute: `true` for the handler's OOB response.
1744    oob: bool,
1745}
1746
1747/// The login stub (`GET /login`).
1748#[derive(Template)]
1749#[template(path = "login.html")]
1750struct LoginTemplate {
1751    /// The link card: this page's own title and description.
1752    card: Card,
1753    repo_url: &'static str,
1754    error: String,
1755    /// A neutral/success banner (e.g. the post-delete "signed out" confirmation),
1756    /// distinct from `error`. Empty renders nothing.
1757    flash: String,
1758}
1759
1760/// The closed-beta invite-redeem page (`GET /beta/redeem`).
1761#[derive(Template)]
1762#[template(path = "beta_redeem.html")]
1763struct BetaRedeemTemplate {
1764    /// The link card: this page's own title and description.
1765    card: Card,
1766    repo_url: &'static str,
1767    error: String,
1768    /// When true the seat cap is full: hide the form and show the "capacity
1769    /// full — try self-hosting" message instead.
1770    capacity_full: bool,
1771}
1772
1773// ---------------------------------------------------------------------------
1774// Rendering + error helpers
1775// ---------------------------------------------------------------------------
1776
1777/// Render an askama template into an HTML response, mapping a render failure to
1778/// a `500` rather than panicking (no `unwrap` in the request path).
1779fn render<T: Template>(tmpl: &T) -> Response {
1780    match tmpl.render() {
1781        Ok(body) => Html(body).into_response(),
1782        Err(err) => {
1783            warn!(%err, "template render failed");
1784            (StatusCode::INTERNAL_SERVER_ERROR, "template render error").into_response()
1785        }
1786    }
1787}
1788
1789/// A minimal web error type so handlers can `?`-propagate `anyhow` failures and
1790/// still return an `impl IntoResponse`. Renders as a `500` with a short message
1791/// by default; a handler may override the status (e.g. `413` for an over-cap
1792/// upload) via [`WebError::with_status`].
1793struct WebError {
1794    err: anyhow::Error,
1795    status: StatusCode,
1796}
1797
1798impl<E: Into<anyhow::Error>> From<E> for WebError {
1799    fn from(err: E) -> Self {
1800        WebError {
1801            err: err.into(),
1802            status: StatusCode::INTERNAL_SERVER_ERROR,
1803        }
1804    }
1805}
1806
1807impl WebError {
1808    /// Attach an explicit HTTP status to render instead of the default `500`.
1809    fn with_status(err: impl Into<anyhow::Error>, status: StatusCode) -> Self {
1810        WebError {
1811            err: err.into(),
1812            status,
1813        }
1814    }
1815}
1816
1817impl IntoResponse for WebError {
1818    fn into_response(self) -> Response {
1819        warn!(error = %self.err, status = %self.status, "request failed");
1820        let body = if self.status == StatusCode::INTERNAL_SERVER_ERROR {
1821            "internal error"
1822        } else {
1823            self.status.canonical_reason().unwrap_or("error")
1824        };
1825        (self.status, body).into_response()
1826    }
1827}
1828
1829/// Map an axum [`MultipartError`] to a [`WebError`] that preserves the error's
1830/// own HTTP status. When a request exceeds the route's `DefaultBodyLimit` the
1831/// multipart extractor reports `413 Payload Too Large`; a malformed body reports
1832/// `400`. Either way this avoids collapsing the failure into a generic `500`.
1833fn multipart_response(err: axum::extract::multipart::MultipartError) -> WebError {
1834    let status = err.status();
1835    WebError::with_status(err, status)
1836}
1837
1838/// A short, human display of a feed/site title for the sidebar/list, falling
1839/// back to the host of a URL and finally to the raw string.
1840fn display_title(title: Option<&str>, url: &str) -> String {
1841    if let Some(t) = title {
1842        let t = t.trim();
1843        if !t.is_empty() {
1844            return t.to_string();
1845        }
1846    }
1847    url::Url::parse(url)
1848        .ok()
1849        .and_then(|u| u.host_str().map(str::to_string))
1850        .unwrap_or_else(|| url.to_string())
1851}
1852
1853/// A display `@handle` for the identity chip: the stored handle if present,
1854/// else the tail of the DID so the chip is never empty.
1855fn display_handle(handle: Option<&str>, did: &str) -> String {
1856    match handle {
1857        Some(h) if !h.trim().is_empty() => format!("@{}", h.trim().trim_start_matches('@')),
1858        _ => did.rsplit(':').next().unwrap_or(did).to_string(),
1859    }
1860}
1861
1862/// Two-letter, lowercase avatar initials from a handle/DID.
1863fn avatar_initials(handle: Option<&str>, did: &str) -> String {
1864    let source = handle
1865        .map(|h| h.trim().trim_start_matches('@'))
1866        .filter(|h| !h.is_empty())
1867        .unwrap_or_else(|| did.rsplit(':').next().unwrap_or(did));
1868    let letters: String = source
1869        .chars()
1870        .filter(|c| c.is_alphanumeric())
1871        .take(2)
1872        .collect::<String>()
1873        .to_lowercase();
1874    if letters.is_empty() {
1875        "fr".to_string()
1876    } else {
1877        letters
1878    }
1879}
1880
1881/// Trim a stored RFC3339 timestamp down to the `YYYY-MM-DD` date for calm,
1882/// low-noise display. Falls back to the raw string if it doesn't look like one.
1883fn display_date(published: Option<&str>) -> String {
1884    // CHARACTERS, not bytes. `p[..10]` panics when byte 10 lands inside a
1885    // multi-byte character, and every caller used to pass a timestamp the feed
1886    // parser had produced. The saved-record path passes `createdAt` straight off
1887    // a PDS record, which the lexicon types as a bare string with no validation
1888    // — written by whatever atproto client the reader used. A `createdAt` of
1889    // "日本語日本語日本" took down the whole starred view, and there is no
1890    // catch-panic layer in the stack, so the page stayed down until the record
1891    // was removed from the very view that would not render.
1892    match published {
1893        Some(p) => p.chars().take(10).collect(),
1894        None => String::new(),
1895    }
1896}
1897
1898/// Percent-encode a value for use in a query string (RFC 3986 unreserved kept).
1899/// Small and dependency-free — the `url` crate's form-encoding isn't exposed for
1900/// a bare value, and this keeps the scope-preserving links honest.
1901fn qenc(s: &str) -> String {
1902    let mut out = String::with_capacity(s.len() * 3);
1903    for b in s.bytes() {
1904        match b {
1905            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
1906                out.push(b as char)
1907            }
1908            _ => out.push_str(&format!("%{b:02X}")),
1909        }
1910    }
1911    out
1912}
1913
1914// ---------------------------------------------------------------------------
1915// Reader: index
1916// ---------------------------------------------------------------------------
1917
1918/// Query for `GET /` — the scope + view selector.
1919#[derive(Debug, Deserialize, Default)]
1920struct IndexQuery {
1921    /// Filter to a single feed by its canonical URL.
1922    #[serde(default)]
1923    feed: Option<String>,
1924    /// Filter to a folder by its `at://` URI (shows every feed in the folder).
1925    #[serde(default)]
1926    folder: Option<String>,
1927    /// `unread` (default) | `all` | `starred`.
1928    #[serde(default)]
1929    view: Option<String>,
1930    /// 1-based page within the selected scope + view. Absent/0 means page 1.
1931    #[serde(default)]
1932    page: Option<u32>,
1933    /// Optional flash message (e.g. after an action redirect).
1934    #[serde(default)]
1935    flash: Option<String>,
1936}
1937
1938/// Rows per page in the reader's list views.
1939///
1940/// The list projection no longer carries article bodies ([`store::EntryListRow`]),
1941/// so a page is on the order of tens of kilobytes rather than the tens or
1942/// hundreds of megabytes an unbounded list of full entries could reach. The page
1943/// bound is the second half of that fix: without it, a reader with a long
1944/// backlog still decides how much memory a single request allocates.
1945const ENTRIES_PER_PAGE: i64 = 100;
1946
1947/// How many pages `total` entries occupy. An empty list is page 1 of 1, so the
1948/// pager reads "1 / 1" rather than "1 / 0".
1949fn page_count_for(total: i64) -> i64 {
1950    ((total + ENTRIES_PER_PAGE - 1) / ENTRIES_PER_PAGE).max(1)
1951}
1952
1953/// Ceiling on the reader's prev/next id list.
1954///
1955/// Unlike the page above, this genuinely spans the whole list — prev/next is the
1956/// reader's position within it — so it is bounded by count rather than paged. At
1957/// 8 bytes per id this is ~40 KB at the cap. Past it the neighbour links stop
1958/// resolving; the article itself still opens, and the list view still pages.
1959const PREV_NEXT_MAX: i64 = 5_000;
1960
1961/// Ceiling on the cached-starred identity set matched against PDS saved records.
1962///
1963/// Deliberately generous: under-reading this set makes a cached article look
1964/// uncached, and an uncached starred row's button deletes the PDS RECORD rather
1965/// than un-starring the entry. Truncating here would change what a click
1966/// destroys, so the cap exists only as a backstop against an absurd starred
1967/// count, not as a routine bound.
1968const STARRED_IDENTITY_MAX: i64 = 20_000;
1969
1970/// Most uncached PDS saved records this handler will hold in memory for one
1971/// request.
1972///
1973/// **A memory bound, not a visibility bound.** These rows are PAGED alongside
1974/// the cached entries, so `ENTRIES_PER_PAGE` decides how many are rendered and
1975/// this only caps how many are collected before slicing. An earlier version used
1976/// it to cap what was SHOWN, which left everything past it invisible and —
1977/// because the un-save control lives on the row, and nothing else in the app
1978/// lists these — unremovable.
1979///
1980/// Well above the PDS list ceiling's practical reach for one reader, so a reader
1981/// meeting it has thousands of saved records and gets a logged, ordered prefix
1982/// rather than a failure.
1983const MAX_UNCACHED_SAVED_ROWS: usize = 5_000;
1984
1985/// A subscription resolved against the local cache: the PDS record + its
1986/// (possibly-missing) cached feed row.
1987struct ResolvedSub {
1988    rkey: String,
1989    sub: Subscription,
1990    feed: Option<store::Feed>,
1991}
1992
1993/// Pull the user's subscriptions (source of truth = PDS), ensure each has a
1994/// local cache row so unread counts work, and return them resolved. Best-effort
1995/// on the sidecar: a failure falls back to the local cache alone.
1996async fn resolve_subscriptions(state: &AppState, did: &str) -> Vec<ResolvedSub> {
1997    resolve_subscriptions_noting(state, did).await.0
1998}
1999
2000/// What to tell a reader whose subscription list could not be read from their
2001/// PDS, so the last-known list being shown does not pass for a fresh one.
2002///
2003/// **A malformed record is named as such** (#177): the walk refuses rather than
2004/// drop that subscription, and "unreachable" would send the reader looking at
2005/// their network when the cause is a record some client wrote into their repo.
2006fn subscriptions_alert(err: &anyhow::Error) -> String {
2007    match err.downcast_ref::<crate::atproto::MalformedRecords>() {
2008        Some(m) => format!(
2009            "{} record(s) in your subscription list could not be read, so it was not \
2010             refreshed. Showing your last-known subscriptions; nothing was removed.",
2011            m.count
2012        ),
2013        None => "Your subscription list could not be read from your PDS just now. \
2014                 Showing your last-known subscriptions."
2015            .to_string(),
2016    }
2017}
2018
2019/// [`resolve_subscriptions`], plus the alert to show when the list shown is the
2020/// cached one because the PDS listing failed.
2021async fn resolve_subscriptions_noting(
2022    state: &AppState,
2023    did: &str,
2024) -> (Vec<ResolvedSub>, Option<String>) {
2025    let pool = &state.db;
2026    let subs = match state.repo().list_subscriptions_sorted(did).await {
2027        Ok(s) => s,
2028        Err(err) => {
2029            let alert = subscriptions_alert(&err);
2030            warn!(%err, %did, "could not list PDS subscriptions; showing this DID's cached subscriptions only");
2031            // Fail CLOSED: the PDS is the source of truth for what this DID
2032            // follows. When it is unreachable we must NOT widen the caller's
2033            // authorization surface. Serve from the DID's OWN last-known
2034            // `sub_ref` projection (its own feeds, possibly stale) and leave
2035            // `sub_ref` untouched — never synthesize from every cached feed,
2036            // which would grant cross-tenant read+mutate during any outage.
2037            // A DB failure here is NOT the same as "this DID follows nothing",
2038            // but `unwrap_or_default` rendered it as exactly that: an empty
2039            // sidebar and an empty reader, which arrives as "all my feeds
2040            // vanished". It still degrades to empty — there is nothing better to
2041            // show — but it says so, so the support ticket and the log line can
2042            // be matched up.
2043            let feeds = store::feeds_for_did(pool, did).await.unwrap_or_else(|err| {
2044                warn!(%err, %did, "the PDS is unreachable AND the local subscription \
2045                                   projection could not be read; rendering an EMPTY \
2046                                   feed list, which is not the same as having none");
2047                Vec::new()
2048            });
2049            let cached = feeds
2050                .into_iter()
2051                .map(|f| ResolvedSub {
2052                    rkey: String::new(),
2053                    sub: Subscription::new(f.url.clone(), now_rfc3339()),
2054                    feed: Some(f),
2055                })
2056                .collect();
2057            return (cached, Some(alert));
2058        }
2059    };
2060
2061    // **Deliberately NOT truncated to `max_subs_per_did`.**
2062    //
2063    // The PDS list is unbounded in practice — any client can write subscription
2064    // records, and only the 20,000-record list ceiling stops it — and the first
2065    // attempt at bounding it truncated the list right here. That was the wrong
2066    // place: `sync_sub_refs` below writes `sub_ref` from exactly this set, and
2067    // `sub_ref` is THE per-DID authorization hook, so dropping entries silently
2068    // removed the reader's ability to read OR mutate those feeds. A query-shape
2069    // problem would have become an access problem.
2070    //
2071    // The shape problem was the scope filter emitting one SQL placeholder per
2072    // feed; `store::list_query_sql` now passes the whole set as a single
2073    // `json_each` bind, so there is no size to defend against here and nothing
2074    // to truncate. `max_subs_per_did` stays what it is — a policy cap on ADDING
2075    // feeds — rather than becoming a silent read-time filter.
2076    let mut out = Vec::with_capacity(subs.len());
2077    for (rkey, sub) in subs {
2078        let feed = match store::get_feed_by_url(pool, &sub.url).await {
2079            Ok(Some(f)) => Some(f),
2080            Ok(None) => {
2081                // `sub.url` came out of an atproto record. The lexicon is open —
2082                // ANY client can write a subscription into a user's repo — so
2083                // this is untrusted input on the hot path of `GET /`, and it was
2084                // being stored with none of the three checks the add and import
2085                // paths apply. Two of those are capacity ceilings; this one is
2086                // the invariant in `FeedPrivacy`'s doc comment, which promises a
2087                // private feed URL is "never stored". Writing a
2088                // `…/feed/private/<token>` into the SHARED `feeds` table breaks
2089                // that promise even though `net::guarded_get` still refuses to
2090                // fetch it.
2091                if !feed::is_storable_feed_url(&sub.url, state.config.standard_site)
2092                    || feed::classify_feed_privacy(&sub.url).is_private()
2093                {
2094                    warn!(
2095                        %did,
2096                        "skipping cache row for a subscription URL that is private or not http(s)"
2097                    );
2098                    out.push(ResolvedSub {
2099                        rkey,
2100                        sub,
2101                        feed: None,
2102                    });
2103                    continue;
2104                }
2105                // Upsert a cache row so the sidebar reflects the real follow-list.
2106                //
2107                // A silent failure here is a support ticket with no evidence: no
2108                // `feeds` row means the poller never selects this subscription,
2109                // so the reader sees "I added a feed and it never updates" while
2110                // the PDS record looks perfect. Logged with the URL so the
2111                // failing subscription is identifiable.
2112                if let Err(err) = store::upsert_feed(
2113                    pool,
2114                    &store::NewFeed {
2115                        url: sub.url.clone(),
2116                        title: sub.title.clone(),
2117                        site_url: sub.site_url.clone(),
2118                        ..Default::default()
2119                    },
2120                )
2121                .await
2122                {
2123                    warn!(%err, url = %sub.url, %did, "could not cache a subscribed feed; \
2124                                                       it will not be polled");
2125                }
2126                store::get_feed_by_url(pool, &sub.url).await.ok().flatten()
2127            }
2128            Err(err) => {
2129                warn!(%err, url = %sub.url, "get_feed_by_url failed");
2130                None
2131            }
2132        };
2133        out.push(ResolvedSub { rkey, sub, feed });
2134    }
2135    // Mirror the caller's resolved subscription set into `sub_ref`, so every
2136    // scoped entry/feed read + read/star mutation authorizes against exactly
2137    // the feeds this DID follows right now. This is THE per-DID isolation hook.
2138    sync_sub_refs(pool, did, &out).await;
2139    (out, None)
2140}
2141
2142/// Refresh the `sub_ref` projection for `did` to exactly the feed ids present
2143/// in `subs`. Best-effort: a failure here only degrades the scoped reads (they
2144/// fail closed / show fewer rows), never leaks another user's entries.
2145async fn sync_sub_refs(pool: &store::Pool, did: &str, subs: &[ResolvedSub]) {
2146    let feed_ids: Vec<i64> = subs
2147        .iter()
2148        .filter_map(|s| s.feed.as_ref().map(|f| f.id))
2149        .collect();
2150    if let Err(err) = store::replace_sub_refs(pool, did, &feed_ids).await {
2151        warn!(%err, %did, "failed to sync sub_ref projection");
2152    }
2153}
2154
2155/// `GET /` — the reader. Renders the sidebar (folders + feeds from the PDS
2156/// records layer) and the article list for the selected scope + view.
2157async fn index(
2158    State(state): State<AppState>,
2159    headers: HeaderMap,
2160    Query(q): Query<IndexQuery>,
2161) -> Result<Response, WebError> {
2162    let user = match current_session(&state, &headers).await {
2163        Some(u) => u,
2164        // Signed out: serve the public landing page rather than bouncing to
2165        // /login. /login remains the entry point for the actual OAuth sign-in.
2166        None => {
2167            return Ok(render(&LandingTemplate {
2168                card: Card::site(&state.config),
2169                version: VERSION,
2170                repo_url: REPO_URL,
2171                crates_url: CRATES_URL,
2172                kofi_url: KOFI_URL,
2173                standard_site: state.config.standard_site,
2174                releases: RELEASES,
2175            }))
2176        }
2177    };
2178    let did = user.did.clone();
2179    let pool = &state.db;
2180
2181    let (subs, alert) = resolve_subscriptions_noting(&state, &did).await;
2182
2183    // View: unread (default) | all | starred.
2184    let view = match q.view.as_deref() {
2185        Some("all") => "all",
2186        Some("starred") => "starred",
2187        _ => "unread",
2188    }
2189    .to_string();
2190    let list_view = list_view_of(q.view.as_deref());
2191
2192    // Which feed URLs are in scope?
2193    let scope_urls = scope_urls_for(&subs, q.feed.as_deref(), q.folder.as_deref());
2194    // …and the feed ids they resolve to. Scope is applied inside the query now,
2195    // so a page is a page of rows the reader will actually see. Filtering after
2196    // a `LIMIT` would have made pages arbitrarily short — sometimes empty — for
2197    // any scope narrower than the whole subscription list.
2198    let scope_ids = scoped_feed_ids(&subs, &scope_urls);
2199
2200    let feed_title_by_id = |id: i64| -> String {
2201        subs.iter()
2202            .find(|s| s.feed.as_ref().map(|f| f.id) == Some(id))
2203            .map(|s| {
2204                display_title(
2205                    s.sub
2206                        .title
2207                        .as_deref()
2208                        .or(s.feed.as_ref().and_then(|f| f.title.as_deref())),
2209                    &s.sub.url,
2210                )
2211            })
2212            .unwrap_or_default()
2213    };
2214
2215    // **One page of the chosen view, filtered, ordered and bounded in SQL.**
2216    //
2217    // All three views used to materialize every matching entry — `SELECT e.*`,
2218    // no `LIMIT`, article bodies included — and the "all" view additionally ran
2219    // one such query PER SUBSCRIBED FEED and merged the results in memory. None
2220    // of the row fields below read the body. See `store::EntryListRow`.
2221    // **Saved records the cache cannot show.**
2222    //
2223    // The starred view is built from local `entries`, so a saved record whose
2224    // article was never cached here is invisible — the case that matters is
2225    // starring in ANOTHER atproto reader, which is the portability the shared
2226    // lexicon exists for. Those rows are rendered from the PDS record alone.
2227    let mut uncached: Vec<EntryRow> = Vec::new();
2228    if view == "starred" {
2229        // **Match against every SUBSCRIBED cached starred entry, not `source`.**
2230        //
2231        // `source` has already been filtered by feed/folder. Matching against it
2232        // meant an entry that IS cached but sits outside the current filter
2233        // looked uncached — so it rendered as a "not cached" row whose star
2234        // button deletes the PDS RECORD instead of un-starring the entry. A
2235        // scope filter must not change what is destroyed. Paging is the same
2236        // hazard in a new form: matching against the visible PAGE would make
2237        // every cached article outside it look uncached. Hence a dedicated
2238        // identity query over the whole starred set — urls and guids only, no
2239        // bodies — rather than reusing `source`.
2240        //
2241        // One gap remains BY DESIGN, and is handled at the other end. This query
2242        // still carries the `sub_ref` predicate, so a starred, cached entry in a
2243        // feed the reader has UNSUBSCRIBED from is absent here and its record
2244        // renders as uncached. That is the right rendering — the article is no
2245        // longer part of any feed the reader follows, and the PDS record is what
2246        // still holds it — but it means the un-save button is the record-deleting
2247        // one. `unsave_record` therefore clears the local star too, so the two
2248        // stores agree however the row got classified. Dropping the predicate
2249        // here instead would have made the row link to `/entries/{id}`, which is
2250        // `sub_ref`-scoped and would 404.
2251        //
2252        // **Three ways this can be unusable, and all three fail CLOSED.** With an
2253        // incomplete identity set, a cached article looks uncached and renders an
2254        // un-save button that deletes the PDS RECORD. Showing no uncached rows
2255        // loses rows for one render; getting this wrong loses data permanently,
2256        // so every uncertain case suppresses them.
2257        let identities = match store::starred_identities(pool, &did, STARRED_IDENTITY_MAX).await {
2258            Ok(store::StarredIdentities::All(rows)) => Some(rows),
2259            // The cap is a memory backstop, and reaching it means the set is an
2260            // arbitrary subset. It used to return that subset with no way to
2261            // tell, so every starred article outside it got the destructive
2262            // button.
2263            Ok(store::StarredIdentities::Truncated) => {
2264                warn!(
2265                    %did,
2266                    cap = STARRED_IDENTITY_MAX,
2267                    "cached-starred set exceeded its cap; suppressing uncached saved rows \
2268                     rather than rendering record-deleting buttons for cached articles"
2269                );
2270                None
2271            }
2272            Err(err) => {
2273                warn!(%err, %did, "cached-starred identity lookup failed; \
2274                                    suppressing uncached saved rows this render");
2275                None
2276            }
2277        };
2278        // The escape hatch asks whether this DID has ANY cached starred entry —
2279        // not whether the current SCOPE does. `total` is narrowed by
2280        // `?feed=`/`?folder=` while the identity set spans every feed, so
2281        // comparing them waved the fail-closed condition through for any narrow
2282        // scope: a record whose `feedUrl` matched the filter while its cached
2283        // entry lived under another feed rendered as uncached.
2284        let identities_ok = identities.is_some();
2285        let identities = identities.unwrap_or_default();
2286        let cached_urls: std::collections::HashSet<&str> = identities
2287            .iter()
2288            .filter_map(|(url, _)| url.as_deref())
2289            .collect();
2290        let cached_guids: std::collections::HashSet<&str> =
2291            identities.iter().map(|(_, guid)| guid.as_str()).collect();
2292
2293        // Collected in full here, sliced per page later. They sort after every
2294        // cached row, so the two lists form one sequence that the pager walks —
2295        // see the slice below. Collected BEFORE the page is chosen because the
2296        // page count depends on how many there are.
2297        // Bounded like everything else on this page. These come from the PDS
2298        // (up to the list ceiling — 20,000 on the sidecar backend, 5,000 on
2299        // `backend=rust`, whose caps are a quarter of the other's) and are
2300        // appended whole to the last page, so `ENTRIES_PER_PAGE` does not
2301        // constrain them at all. The
2302        // cap is generous — a reader with more saved-elsewhere records than this
2303        // is not the case being designed for — but a response has to have a size
2304        // an operator can reason about.
2305        let mut uncached_dropped = 0usize;
2306        match state.repo().list_saved_sorted(&did).await {
2307            Ok(saved) if identities_ok => {
2308                for (rkey, item) in saved {
2309                    let known = cached_urls.contains(item.url.as_str())
2310                        || item
2311                            .entry_id
2312                            .as_deref()
2313                            .is_some_and(|g| cached_guids.contains(g));
2314                    if known {
2315                        continue;
2316                    }
2317                    // And the scope filter applies to these rows too. Without
2318                    // it, `?feed=X` still listed saved records from every other
2319                    // feed — the filter silently did nothing for them.
2320                    if let Some(urls) = &scope_urls {
2321                        match item.feed_url.as_deref() {
2322                            Some(feed_url) if urls.iter().any(|u| u == feed_url) => {}
2323                            // A saved record with no `feedUrl` cannot be placed
2324                            // in any feed's scope, so it belongs only to the
2325                            // unfiltered view.
2326                            _ => continue,
2327                        }
2328                    }
2329                    // **`safe_link` FIRST, and a failure no longer drops the row.**
2330                    //
2331                    // `item.url` is attacker-controlled — a saved record written
2332                    // by any client — and it lands in an `href`. Askama escapes
2333                    // HTML metacharacters but not SCHEMES, so `javascript:`
2334                    // survives escaping intact. This project already built the
2335                    // helper for exactly that, and `feed.rs` uses it on the
2336                    // equivalent link; this path was simply not routed through it.
2337                    //
2338                    // The real defect was what a failure DID: it `continue`d, so
2339                    // the row vanished entirely — no badge, no count, nothing —
2340                    // and the only trace was a `debug!` below any realistic
2341                    // filter. That makes the record unremovable FROM HERE, because
2342                    // the un-save button lives on the row; the reader has to open
2343                    // a different atproto client to get rid of it. A bad URL is a
2344                    // reason to withhold the LINK, not the row.
2345                    //
2346                    // The check also moved ABOVE the poll nudge. That is ordering
2347                    // hygiene rather than a fix: the nudge keys on `feed_url`, not
2348                    // on the URL being rejected here, and is already gated on the
2349                    // reader actually subscribing to that feed — so it was never
2350                    // reachable by an unusable `item.url`. Deciding whether a
2351                    // record is renderable before doing anything outbound on its
2352                    // behalf is simply the order that stays correct if either of
2353                    // those two facts later stops being true.
2354                    let link = SafeLink::external(&item.url);
2355                    if link.is_empty() {
2356                        warn!(
2357                            %did, %rkey,
2358                            "a saved record has an unusable URL; rendering it without a link \
2359                             so it can still be removed"
2360                        );
2361                    }
2362
2363                    // Opportunistic re-fetch: if the reader still subscribes to
2364                    // the feed, make it due now. If the article is still inside
2365                    // the feed's window the poller caches it normally and this
2366                    // row becomes a real entry on its own — no synthetic rows in
2367                    // the shared cache, which every subscriber would otherwise
2368                    // see as a content-less entry.
2369                    // **Bound the WORK, not just the response.** This check sat
2370                    // after the nudge and the `subs` scan below, so every render
2371                    // still walked all ≤20,000 PDS records, ran a subs-length
2372                    // string scan per record, and issued up to that many
2373                    // `mark_feed_due` round-trips on a 5-connection pool — then
2374                    // discarded everything past the cap. A cap that runs after
2375                    // the expensive part is a cap on the output only.
2376                    if uncached.len() >= MAX_UNCACHED_SAVED_ROWS {
2377                        uncached_dropped += 1;
2378                        continue;
2379                    }
2380                    if let Some(feed_url) = item.feed_url.as_deref() {
2381                        if subs.iter().any(|s| s.sub.url == feed_url) {
2382                            // Bounded to one nudge per feed per poll interval —
2383                            // see `mark_feed_due`. Unbounded, a reload loop here
2384                            // becomes outbound amplification.
2385                            let stale_before = (chrono::Utc::now()
2386                                - chrono::Duration::from_std(state.config.poll_interval)
2387                                    .unwrap_or_else(|_| chrono::Duration::hours(1)))
2388                            .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
2389                            if let Err(err) =
2390                                store::mark_feed_due(pool, feed_url, &stale_before).await
2391                            {
2392                                tracing::debug!(%err, %feed_url, "could not nudge a feed for a saved article");
2393                            }
2394                        }
2395                    }
2396                    uncached.push(EntryRow {
2397                        id: 0,
2398                        title: item
2399                            .title
2400                            .clone()
2401                            .filter(|t| !t.trim().is_empty())
2402                            // Falling back to the URL is fine for a link we are
2403                            // willing to render, and wrong for one we are not:
2404                            // it would put the exact string `safe_link` just
2405                            // rejected into the page as the record's name. The
2406                            // rkey is what the un-save button acts on, so it is
2407                            // the honest identifier for a row that has nothing
2408                            // else trustworthy to show.
2409                            .unwrap_or_else(|| {
2410                                if link.is_empty() {
2411                                    format!("Saved item {rkey}")
2412                                } else {
2413                                    item.url.clone()
2414                                }
2415                            }),
2416                        feed_title: item.feed_url.clone().unwrap_or_default(),
2417                        published: display_date(Some(&item.created_at)),
2418                        read: false,
2419                        starred: true,
2420                        // Empty = "render this row without an anchor". The
2421                        // template branches on it, so the rejected URL never
2422                        // reaches an `href` even as an escaped string.
2423                        link,
2424                        cached: false,
2425                        rkey,
2426                    });
2427                }
2428            }
2429            // Identity lookup was unusable — see the fail-closed note above.
2430            Ok(_) => {}
2431            Err(err) => warn!(%err, %did, "could not list saved records from the PDS"),
2432        }
2433        if uncached_dropped > 0 {
2434            warn!(
2435                %did,
2436                dropped = uncached_dropped,
2437                cap = MAX_UNCACHED_SAVED_ROWS,
2438                "more saved records than this instance will hold in one response; the \
2439                 rest are not reachable from here"
2440            );
2441        }
2442    }
2443
2444    // **One sequence, two sources.** The cached rows come from SQL, the uncached
2445    // PDS records follow them, and the pager walks the concatenation.
2446    //
2447    // The first version appended the uncached rows to the last page only and
2448    // kept them out of `total`, which left everything past a cap invisible AND
2449    // unremovable — the un-save button lives on the row, and there is no other
2450    // surface in the app that lists these. That is the same "unremovable FROM
2451    // HERE" hazard the `safe_link` fix above exists to prevent, reintroduced
2452    // forty lines later by a bound meant to protect memory.
2453    //
2454    // Paging the concatenation makes every record reachable and needs no cap on
2455    // what is RENDERED — one page is one page either way. The version before
2456    // that inflated `total` while clamping on the cached count, which advertised
2457    // a page the clamp could never reach; both numbers come from the same total
2458    // now, which is what makes that impossible rather than merely fixed.
2459    let total_cached =
2460        store::count_entries_for_view(pool, &did, list_view, scope_ids.as_deref()).await?;
2461    let uncached_len = uncached.len();
2462    let total = total_cached + uncached_len as i64;
2463    // Clamped to the range that exists. Past the end the list is empty, and the
2464    // empty state renders instead of the pager — which would strand a reader who
2465    // typed a page number, or who paged to the end and then marked entries read
2466    // out from under their own URL. Showing the last page is the answer to both.
2467    let page = i64::from(q.page.unwrap_or(1).max(1)).min(page_count_for(total));
2468    let offset = (page - 1) * ENTRIES_PER_PAGE;
2469    // Past the cached rows this returns nothing, which is exactly right: the
2470    // page is then made up entirely of uncached ones.
2471    let source = store::list_entries(
2472        pool,
2473        &did,
2474        list_view,
2475        scope_ids.as_deref(),
2476        ENTRIES_PER_PAGE,
2477        offset,
2478    )
2479    .await?;
2480    // **Both halves of the page are computed from the COUNT alone.**
2481    //
2482    // `total_cached` (a COUNT) and `source` (a SELECT) are separate unsynchronised
2483    // queries, so they can disagree about how many cached rows exist. Any part of
2484    // the page composition that reads `source.len()` inherits that disagreement.
2485    //
2486    // `cached_allotment` is this page's cached share according to the snapshot,
2487    // and it is what the uncached `skip`/`take` are derived from — so consecutive
2488    // pages tile the uncached list exactly, whichever way the count drifted.
2489    // `source` is then truncated to it only to avoid rendering rows the next page
2490    // will also claim.
2491    //
2492    // The previous version took `skip` from the count but `take` from
2493    // `source.len()`, which agreed only when the count UNDERSTATED. Overstating —
2494    // an un-star or a retention delete landing between the two queries — made
2495    // page N render `uncached[0..70]` while page N+1 rendered `uncached[50..80]`,
2496    // putting twenty rows, each carrying the record-DELETING un-save button, on
2497    // two pages at once. The comment claimed that shape was impossible; it was
2498    // merely rarer.
2499    let cached_allotment = (total_cached - offset).clamp(0, ENTRIES_PER_PAGE) as usize;
2500    let cached_here = cached_allotment.min(source.len());
2501    // Only compose when there is something to compose WITH. `uncached` is empty
2502    // on every view but `starred`, and truncating there just drops trailing rows
2503    // that no page then shows — the poller inserting between the COUNT and the
2504    // SELECT was enough to trigger it.
2505    let source = if uncached_len == 0 {
2506        &source[..]
2507    } else {
2508        &source[..cached_here]
2509    };
2510    let uncached_page: Vec<EntryRow> = {
2511        let skip = (offset - total_cached).max(0) as usize;
2512        let take = (ENTRIES_PER_PAGE as usize) - cached_allotment;
2513        uncached.into_iter().skip(skip).take(take).collect()
2514    };
2515    // This page's slice, used only to append below. The heading needs the
2516    // WHOLE-list figure, which is the set's size before slicing.
2517    let uncached_total = uncached_len as i64;
2518
2519    // The scope/view suffix carried onto every entry link (built once).
2520    let entry_scope_qs = {
2521        let mut parts = Vec::new();
2522        if let Some(f) = q.feed.as_deref() {
2523            parts.push(format!("feed={}", qenc(f)));
2524        }
2525        if let Some(f) = q.folder.as_deref() {
2526            parts.push(format!("folder={}", qenc(f)));
2527        }
2528        if view != "unread" {
2529            parts.push(format!("view={}", qenc(&view)));
2530        }
2531        parts.join("&")
2532    };
2533    let entries: Vec<EntryRow> = source
2534        .iter()
2535        .map(|e| EntryRow {
2536            id: e.id,
2537            title: e
2538                .title
2539                .clone()
2540                .filter(|t| !t.trim().is_empty())
2541                .unwrap_or_else(|| "(untitled)".to_string()),
2542            feed_title: feed_title_by_id(e.feed_id),
2543            published: display_date(e.published.as_deref()),
2544            // Both bits ride along on the row's own `entry_state` join now. They
2545            // used to be membership tests against the full unread and starred
2546            // sets, which is why those two lists were fetched in their entirety
2547            // on every render even when the page showed a hundred rows.
2548            read: e.read,
2549            starred: e.starred,
2550            link: SafeLink::entry(e.id, &entry_scope_qs),
2551            cached: true,
2552            rkey: String::new(),
2553        })
2554        .collect();
2555
2556    // The uncached slice for this page follows the cached rows.
2557    let mut entries = entries;
2558    entries.extend(uncached_page);
2559    let entries = entries;
2560
2561    let selected_feed = q.feed.as_deref();
2562    let selected_folder = q.folder.as_deref();
2563
2564    // Build the shared sidebar (folders + loose feeds, with unread counts).
2565    let (folder_views, loose_feeds, _folder_options) =
2566        build_sidebar(&state, &did, &subs, selected_feed, selected_folder).await;
2567
2568    // Heading + scope query-string suffix.
2569    let (heading, scope_qs) = if let Some(feed_url) = selected_feed {
2570        let name = subs
2571            .iter()
2572            .find(|s| s.sub.url == feed_url)
2573            .map(|s| {
2574                display_title(
2575                    s.sub
2576                        .title
2577                        .as_deref()
2578                        .or(s.feed.as_ref().and_then(|f| f.title.as_deref())),
2579                    &s.sub.url,
2580                )
2581            })
2582            .unwrap_or_else(|| display_title(None, feed_url));
2583        (name, format!("feed={}", qenc(feed_url)))
2584    } else if let Some(folder_uri) = selected_folder {
2585        let name = folder_views
2586            .iter()
2587            .find(|f| f.uri == folder_uri)
2588            .map(|f| f.name.clone())
2589            .unwrap_or_else(|| "Folder".to_string());
2590        (name, format!("folder={}", qenc(folder_uri)))
2591    } else {
2592        let h = match view.as_str() {
2593            "all" => "All",
2594            "starred" => "Starred",
2595            _ => "Unread",
2596        };
2597        (h.to_string(), String::new())
2598    };
2599
2600    let feed_scope = selected_feed.map(str::to_string);
2601    let nav = build_nav(&user, &view, scope_qs, folder_views, loose_feeds, false);
2602
2603    // Pager links. `entry_scope_qs` already carries feed/folder/view, so the
2604    // page number is the only thing appended — which keeps a paged link
2605    // identical to an unpaged one in every other respect.
2606    let page_href = |n: i64| -> String {
2607        let mut parts = Vec::new();
2608        if !entry_scope_qs.is_empty() {
2609            parts.push(entry_scope_qs.clone());
2610        }
2611        if n > 1 {
2612            parts.push(format!("page={n}"));
2613        }
2614        if parts.is_empty() {
2615            "/".to_string()
2616        } else {
2617            format!("/?{}", parts.join("&"))
2618        }
2619    };
2620    let prev_href = (page > 1).then(|| page_href(page - 1));
2621    let next_href = (page * ENTRIES_PER_PAGE < total).then(|| page_href(page + 1));
2622
2623    let tmpl = IndexTemplate {
2624        card: Card::private(&state.config),
2625        version: VERSION,
2626        repo_url: REPO_URL,
2627        kofi_url: KOFI_URL,
2628        flash: q.flash.unwrap_or_default(),
2629        alert: alert.unwrap_or_default(),
2630        nav,
2631        entries,
2632        heading,
2633        feed_scope,
2634        total,
2635        // Whole-list figure, so it sits beside `total` without double counting.
2636        // The per-page slice is composed above and is not a heading number.
2637        uncached_total,
2638        page,
2639        page_count: page_count_for(total),
2640        prev_href,
2641        next_href,
2642    };
2643    Ok(render(&tmpl))
2644}
2645
2646/// Query for `GET /manage` — carries an optional flash after an action redirect.
2647#[derive(Debug, Deserialize, Default)]
2648struct ManageQuery {
2649    #[serde(default)]
2650    flash: Option<String>,
2651}
2652
2653/// `GET /manage` — the feed-management page. Renders the rail plus the subscribe
2654/// / your-feeds / OPML surfaces; the forms POST to the existing routes
2655/// (`/subscriptions`, `/folders`, `/opml`, …). A read/render route only — no
2656/// mutation logic of its own.
2657async fn manage(
2658    State(state): State<AppState>,
2659    headers: HeaderMap,
2660    Query(q): Query<ManageQuery>,
2661) -> Result<Response, WebError> {
2662    let user = match current_session(&state, &headers).await {
2663        Some(u) => u,
2664        None => return Ok(Redirect::to("/login").into_response()),
2665    };
2666    let did = user.did.clone();
2667
2668    let (subs, alert) = resolve_subscriptions_noting(&state, &did).await;
2669    let (folder_views, loose_feeds, folder_options) =
2670        build_sidebar(&state, &did, &subs, None, None).await;
2671
2672    // Clone the sidebar for the rail; the page body reuses folders/loose feeds.
2673    let nav = build_nav(
2674        &user,
2675        "unread",
2676        String::new(),
2677        folder_views.iter().map(clone_folder_view).collect(),
2678        loose_feeds.iter().map(clone_feed_view).collect(),
2679        true,
2680    );
2681
2682    let tmpl = ManageTemplate {
2683        card: Card::private(&state.config),
2684        version: VERSION,
2685        repo_url: REPO_URL,
2686        kofi_url: KOFI_URL,
2687        flash: q.flash.unwrap_or_default(),
2688        alert: alert.unwrap_or_default(),
2689        nav,
2690        folder_options,
2691        folders: folder_views,
2692        loose_feeds,
2693        standard_site: state.config.standard_site,
2694    };
2695    Ok(render(&tmpl))
2696}
2697
2698/// Shallow clone helpers so `/manage` can hand the same sidebar to both the rail
2699/// (`Nav`) and the page body without an extra DB round-trip.
2700fn clone_feed_view(f: &FeedView) -> FeedView {
2701    FeedView {
2702        rkey: f.rkey.clone(),
2703        url: f.url.clone(),
2704        title: f.title.clone(),
2705        unread: f.unread,
2706        selected: f.selected,
2707        folder: f.folder.clone(),
2708    }
2709}
2710
2711fn clone_folder_view(f: &FolderView) -> FolderView {
2712    FolderView {
2713        rkey: f.rkey.clone(),
2714        uri: f.uri.clone(),
2715        name: f.name.clone(),
2716        feeds: f.feeds.iter().map(clone_feed_view).collect(),
2717        selected: f.selected,
2718    }
2719}
2720
2721/// The set of feed URLs a scope covers: `Some([one url])` for a single-feed
2722/// scope, `Some([urls…])` for a folder (its member feeds), or `None` for the
2723/// unscoped "everything" view. A folder scope takes the feed scope when both are
2724/// somehow present (feed wins, matching the query precedence elsewhere).
2725fn scope_urls_for(
2726    subs: &[ResolvedSub],
2727    feed: Option<&str>,
2728    folder: Option<&str>,
2729) -> Option<Vec<String>> {
2730    if let Some(feed_url) = feed {
2731        Some(vec![feed_url.to_string()])
2732    } else {
2733        folder.map(|folder_uri| {
2734            subs.iter()
2735                .filter(|s| s.sub.folder.as_deref() == Some(folder_uri))
2736                .map(|s| s.sub.url.clone())
2737                .collect()
2738        })
2739    }
2740}
2741
2742/// The `at://` URI for a folder record given the owner DID + rkey.
2743fn folder_uri(did: &str, rkey: &str) -> String {
2744    format!("at://{did}/{}/{rkey}", lexicon::nsid::FOLDER)
2745}
2746
2747/// Build the sidebar folder/loose-feed views (with per-feed unread counts) for a
2748/// DID — the shared source for both the reader index and the rail on every
2749/// chrome page. `selected_feed` / `selected_folder` drive `aria-current`.
2750async fn build_sidebar(
2751    state: &AppState,
2752    did: &str,
2753    subs: &[ResolvedSub],
2754    selected_feed: Option<&str>,
2755    selected_folder: Option<&str>,
2756) -> (Vec<FolderView>, Vec<FeedView>, Vec<FolderOption>) {
2757    let pool = &state.db;
2758    // Counted in SQL. This used to fetch every unread ENTRY — article bodies and
2759    // all — purely to `.filter().count()` them in Rust, on every page that
2760    // renders chrome, which made the sidebar the most frequently executed
2761    // instance of the unbounded-projection problem.
2762    let unread_counts = store::unread_counts_by_feed(pool, did)
2763        .await
2764        .unwrap_or_else(|err| {
2765            warn!(%err, %did, "sidebar unread counts failed; rendering zeroes");
2766            Default::default()
2767        });
2768    let folders = state
2769        .repo()
2770        .list_folders_sorted(did)
2771        .await
2772        .unwrap_or_default();
2773
2774    let unread_count = |feed_id: Option<i64>| -> i64 {
2775        feed_id
2776            .and_then(|id| unread_counts.get(&id).copied())
2777            .unwrap_or(0)
2778    };
2779    let mk_feed_view = |s: &ResolvedSub| FeedView {
2780        rkey: s.rkey.clone(),
2781        url: s.sub.url.clone(),
2782        title: display_title(
2783            s.sub
2784                .title
2785                .as_deref()
2786                .or(s.feed.as_ref().and_then(|f| f.title.as_deref())),
2787            &s.sub.url,
2788        ),
2789        unread: unread_count(s.feed.as_ref().map(|f| f.id)),
2790        selected: selected_feed == Some(s.sub.url.as_str()),
2791        folder: s.sub.folder.clone(),
2792    };
2793
2794    let mut folder_views = Vec::with_capacity(folders.len());
2795    for (rkey, folder) in &folders {
2796        let uri = folder_uri(did, rkey);
2797        let feeds: Vec<FeedView> = subs
2798            .iter()
2799            .filter(|s| s.sub.folder.as_deref() == Some(uri.as_str()))
2800            .map(mk_feed_view)
2801            .collect();
2802        folder_views.push(FolderView {
2803            rkey: rkey.clone(),
2804            uri: uri.clone(),
2805            name: folder.name.clone(),
2806            feeds,
2807            selected: selected_folder == Some(uri.as_str()),
2808        });
2809    }
2810
2811    let known_uris: std::collections::HashSet<String> =
2812        folders.iter().map(|(r, _)| folder_uri(did, r)).collect();
2813    let loose_feeds: Vec<FeedView> = subs
2814        .iter()
2815        .filter(|s| {
2816            s.sub
2817                .folder
2818                .as_deref()
2819                .map(|f| !known_uris.contains(f))
2820                .unwrap_or(true)
2821        })
2822        .map(mk_feed_view)
2823        .collect();
2824
2825    let folder_options: Vec<FolderOption> = folders
2826        .iter()
2827        .map(|(rkey, folder)| FolderOption {
2828            name: folder.name.clone(),
2829            uri: folder_uri(did, rkey),
2830        })
2831        .collect();
2832
2833    (folder_views, loose_feeds, folder_options)
2834}
2835
2836/// Assemble the shared rail [`Nav`] for a chrome page.
2837fn build_nav(
2838    user: &CurrentUser,
2839    view: &str,
2840    scope_qs: String,
2841    folders: Vec<FolderView>,
2842    loose_feeds: Vec<FeedView>,
2843    manage_active: bool,
2844) -> Nav {
2845    Nav {
2846        handle: display_handle(user.handle.as_deref(), &user.did),
2847        avatar: avatar_initials(user.handle.as_deref(), &user.did),
2848        view: view.to_string(),
2849        scope_qs,
2850        folders,
2851        loose_feeds,
2852        manage_active,
2853    }
2854}
2855
2856// ---------------------------------------------------------------------------
2857// Reader: single entry
2858// ---------------------------------------------------------------------------
2859
2860/// Query for `GET /entries/:id` — carries the reading context (scope + view) so
2861/// prev/next and "back" stay within the list the reader came from.
2862#[derive(Debug, Deserialize, Default)]
2863struct EntryQuery {
2864    #[serde(default)]
2865    feed: Option<String>,
2866    #[serde(default)]
2867    folder: Option<String>,
2868    #[serde(default)]
2869    view: Option<String>,
2870}
2871
2872/// `GET /entries/:id` — the clean reader view for one entry, with prev/next
2873/// within the current reading list.
2874async fn entry_view(
2875    State(state): State<AppState>,
2876    headers: HeaderMap,
2877    Path(id): Path<i64>,
2878    Query(q): Query<EntryQuery>,
2879) -> Result<Response, WebError> {
2880    let user = match current_session(&state, &headers).await {
2881        Some(u) => u,
2882        None => return Ok(Redirect::to("/login").into_response()),
2883    };
2884    let did = user.did.clone();
2885    let pool = &state.db;
2886
2887    // Resolve subscriptions FIRST: this refreshes the `sub_ref` projection so
2888    // the per-DID entry gate below authorizes against the caller's current PDS
2889    // subscription set (not another user's cached feeds).
2890    let subs = resolve_subscriptions(&state, &did).await;
2891
2892    let entry = match get_entry_by_id(pool, &did, id).await? {
2893        Some(e) => e,
2894        None => return Ok((StatusCode::NOT_FOUND, "entry not found").into_response()),
2895    };
2896
2897    let feed_title = feed_title_by_entry(pool, entry.feed_id).await;
2898
2899    let read = entry_is_read(pool, &did, id).await?;
2900    let starred = entry_is_starred(pool, &did, id).await?;
2901
2902    // Reconstruct the current list to compute prev/next, so paging in the reader
2903    // matches what the list showed.
2904    let (prev_id, next_id) = neighbors_in_scope(&state, &did, &q, id).await;
2905
2906    let back_qs = scope_query(&q);
2907
2908    let (folder_views, loose_feeds, _) =
2909        build_sidebar(&state, &did, &subs, q.feed.as_deref(), q.folder.as_deref()).await;
2910    let nav_view = match q.view.as_deref() {
2911        Some("all") => "all",
2912        Some("starred") => "starred",
2913        _ => "unread",
2914    };
2915    let nav = build_nav(
2916        &user,
2917        nav_view,
2918        back_qs.clone(),
2919        folder_views,
2920        loose_feeds,
2921        false,
2922    );
2923
2924    let tmpl = EntryTemplate {
2925        card: Card::private(&state.config),
2926        version: VERSION,
2927        repo_url: REPO_URL,
2928        kofi_url: KOFI_URL,
2929        nav,
2930        id: entry.id,
2931        title: entry
2932            .title
2933            .clone()
2934            .filter(|t| !t.trim().is_empty())
2935            .unwrap_or_else(|| "(untitled)".to_string()),
2936        feed_title,
2937        author: entry.author.clone().filter(|a| !a.trim().is_empty()),
2938        published: display_date(entry.published.as_deref()),
2939        url: entry.url.as_deref().and_then(SafeLink::external_opt),
2940        content_html: entry.content_html.clone(),
2941        read,
2942        starred,
2943        back_qs,
2944        prev_id,
2945        next_id,
2946        oob: false,
2947    };
2948    Ok(render(&tmpl))
2949}
2950
2951/// Compute the prev/next entry ids around `current` within the reader's current
2952/// scope + view, so the reader view can offer keyboard/paging navigation.
2953async fn neighbors_in_scope(
2954    state: &AppState,
2955    did: &str,
2956    q: &EntryQuery,
2957    current: i64,
2958) -> (Option<i64>, Option<i64>) {
2959    let idx_q = IndexQuery {
2960        feed: q.feed.clone(),
2961        folder: q.folder.clone(),
2962        view: q.view.clone(),
2963        // Neighbours span the whole list, not the page the reader arrived from.
2964        page: None,
2965        flash: None,
2966    };
2967    let ids = list_entry_ids(state, did, &idx_q).await;
2968    let pos = ids.iter().position(|&x| x == current);
2969    match pos {
2970        Some(p) => {
2971            let prev = if p > 0 { Some(ids[p - 1]) } else { None };
2972            let next = ids.get(p + 1).copied();
2973            (prev, next)
2974        }
2975        None => (None, None),
2976    }
2977}
2978
2979/// The ordered entry ids for a scope + view — the same ordering `index` renders,
2980/// used for reader prev/next. Best-effort; PDS failures degrade to local cache.
2981async fn list_entry_ids(state: &AppState, did: &str, q: &IndexQuery) -> Vec<i64> {
2982    let pool = &state.db;
2983    let subs = resolve_subscriptions(state, did).await;
2984
2985    let scope_urls = scope_urls_for(&subs, q.feed.as_deref(), q.folder.as_deref());
2986
2987    // Ids only, and bounded. This used to fetch whole entries — bodies included
2988    // — for all three views and then throw everything but `id` away; the "all"
2989    // branch additionally ran one unbounded query PER FEED and sorted the union
2990    // in memory. Scope is now a feed-id restriction inside the query, so the
2991    // database does the filtering and the ordering exactly once.
2992    store::list_entry_ids(
2993        pool,
2994        did,
2995        list_view_of(q.view.as_deref()),
2996        scoped_feed_ids(&subs, &scope_urls).as_deref(),
2997        PREV_NEXT_MAX,
2998    )
2999    .await
3000    .unwrap_or_else(|err| {
3001        warn!(%err, %did, "prev/next id list failed; the reader loses its neighbour links");
3002        Vec::new()
3003    })
3004}
3005
3006/// Map the `?view=` query value onto the store's list view. Anything
3007/// unrecognised is the unread default, matching `index`.
3008fn list_view_of(view: Option<&str>) -> store::ListView {
3009    match view {
3010        Some("all") => store::ListView::All,
3011        Some("starred") => store::ListView::Starred,
3012        _ => store::ListView::Unread,
3013    }
3014}
3015
3016/// Translate a feed/folder scope into the feed ids to restrict a list query to.
3017///
3018/// `None` means unscoped (every subscribed feed). `Some(&[])` means the scope
3019/// matched no local feed, which must return nothing rather than everything — so
3020/// the empty vec is deliberately preserved, not collapsed back into `None`.
3021fn scoped_feed_ids(subs: &[ResolvedSub], scope_urls: &Option<Vec<String>>) -> Option<Vec<i64>> {
3022    let urls = scope_urls.as_ref()?;
3023    Some(
3024        subs.iter()
3025            .filter(|s| urls.contains(&s.sub.url))
3026            .filter_map(|s| s.feed.as_ref().map(|f| f.id))
3027            .collect(),
3028    )
3029}
3030
3031/// Build a `?…` query string that preserves the reading scope + view for links.
3032fn scope_query(q: &EntryQuery) -> String {
3033    let mut parts = Vec::new();
3034    if let Some(f) = q.feed.as_deref() {
3035        parts.push(format!("feed={}", qenc(f)));
3036    }
3037    if let Some(f) = q.folder.as_deref() {
3038        parts.push(format!("folder={}", qenc(f)));
3039    }
3040    if let Some(v) = q.view.as_deref() {
3041        if v != "unread" {
3042            parts.push(format!("view={}", qenc(v)));
3043        }
3044    }
3045    parts.join("&")
3046}
3047
3048// ---------------------------------------------------------------------------
3049// Mark read / unread
3050// ---------------------------------------------------------------------------
3051
3052/// Form body for `POST /entries/:id/read`.
3053#[derive(Debug, Deserialize)]
3054struct ReadForm {
3055    #[serde(default)]
3056    read: Option<String>,
3057}
3058
3059/// `POST /entries/:id/read` — toggle an entry's read-state for the current DID.
3060async fn mark_read(
3061    State(state): State<AppState>,
3062    Path(id): Path<i64>,
3063    headers: HeaderMap,
3064    Form(form): Form<ReadForm>,
3065) -> Result<Response, WebError> {
3066    let did = match current_did(&state, &headers).await {
3067        Some(d) => d,
3068        None => return Ok(Redirect::to("/login").into_response()),
3069    };
3070    let pool = &state.db;
3071
3072    let read = matches!(
3073        form.read.as_deref(),
3074        Some("true") | Some("1") | Some("on") | None
3075    );
3076
3077    // Refresh the caller's `sub_ref` projection, then apply the AUTHORIZED
3078    // mutation: `mark_read` only writes when `did` subscribes to the entry's
3079    // feed. A non-subscriber gets a 404, never a mutation of someone else's
3080    // (or the shared cache's) state.
3081    resolve_subscriptions(&state, &did).await;
3082    if !store::mark_read(pool, &did, id, read).await? {
3083        return Ok((StatusCode::NOT_FOUND, "entry not found").into_response());
3084    }
3085
3086    if !is_htmx(&headers) {
3087        return Ok(Redirect::to("/").into_response());
3088    }
3089
3090    // The reader view swaps an out-of-band action-bar fragment (its `<li>` isn't
3091    // in the DOM), so its button's hidden value + aria-pressed update in place
3092    // and a second keypress can reverse the toggle. The list view swaps the row.
3093    if is_reader_request(&headers) {
3094        let starred = entry_is_starred(pool, &did, id).await?;
3095        return Ok(render(&EntryActionBarTemplate {
3096            id,
3097            read,
3098            starred,
3099            oob: true,
3100        }));
3101    }
3102
3103    let row = build_entry_row(pool, &did, id, Some(read)).await?;
3104    match row {
3105        Some(r) => Ok(render(&EntryRowTemplate { e: r })),
3106        None => Ok((StatusCode::NOT_FOUND, "entry not found").into_response()),
3107    }
3108}
3109
3110// ---------------------------------------------------------------------------
3111// Star / save
3112// ---------------------------------------------------------------------------
3113
3114/// Form body for `POST /entries/:id/star`.
3115#[derive(Debug, Deserialize)]
3116struct StarForm {
3117    #[serde(default)]
3118    starred: Option<String>,
3119}
3120
3121/// `POST /entries/:id/star` — star/unstar an entry.
3122///
3123/// Sets the local `starred` bit (fast working copy) and writes/removes a
3124/// `community.lexicon.rss.saved` record in the user's PDS (stars are worth
3125/// owning). The PDS write is best-effort — the local star still lands.
3126async fn toggle_star(
3127    State(state): State<AppState>,
3128    Path(id): Path<i64>,
3129    headers: HeaderMap,
3130    Form(form): Form<StarForm>,
3131) -> Result<Response, WebError> {
3132    let did = match current_did(&state, &headers).await {
3133        Some(d) => d,
3134        None => return Ok(Redirect::to("/login").into_response()),
3135    };
3136    let pool = &state.db;
3137
3138    let starred = matches!(
3139        form.starred.as_deref(),
3140        Some("true") | Some("1") | Some("on") | None
3141    );
3142
3143    // Refresh the caller's `sub_ref` projection, then apply the AUTHORIZED
3144    // mutation: `mark_starred` only writes when `did` subscribes to the entry's
3145    // feed. A non-subscriber gets a 404, never a mutation.
3146    resolve_subscriptions(&state, &did).await;
3147    if !store::mark_starred(pool, &did, id, starred).await? {
3148        return Ok((StatusCode::NOT_FOUND, "entry not found").into_response());
3149    }
3150
3151    // Reflect into the PDS saved-records collection. `get_entry_by_id` is scoped
3152    // to the caller's subscriptions, so this only ever acts on the caller's feed.
3153    if let Ok(Some(entry)) = get_entry_by_id(pool, &did, id).await {
3154        let entry_url = entry.url.clone().unwrap_or_default();
3155        if !entry_url.is_empty() {
3156            if starred {
3157                let mut saved = Saved::new(entry_url.clone(), now_rfc3339());
3158                saved.title = entry.title.clone();
3159                saved.feed_url = feed_url_for_id(pool, entry.feed_id).await;
3160                saved.entry_id = Some(entry.guid.clone());
3161                match state.repo().add_saved(&did, &saved).await {
3162                    Ok(rkey) => info!(%did, url = %entry_url, %rkey, "wrote saved record to PDS"),
3163                    Err(err) => warn!(%err, %did, "PDS saved write failed (starred locally)"),
3164                }
3165            } else {
3166                // Un-star: find and delete the matching saved record by URL.
3167                match state.repo().list_saved(&did).await {
3168                    Ok(records) => {
3169                        for (rkey, _rec) in records.iter().filter(|(_, r)| r.url == entry_url) {
3170                            if let Err(err) = state.repo().remove_saved(&did, rkey).await {
3171                                warn!(%err, %did, %rkey, "PDS saved delete failed");
3172                            }
3173                        }
3174                    }
3175                    Err(err) => warn!(%err, %did, "could not list saved records to un-star"),
3176                }
3177            }
3178        }
3179    }
3180
3181    if !is_htmx(&headers) {
3182        return Ok(Redirect::to("/").into_response());
3183    }
3184
3185    // Reader → out-of-band action-bar fragment; list → the row (see mark_read).
3186    if is_reader_request(&headers) {
3187        let read = entry_is_read(pool, &did, id).await?;
3188        return Ok(render(&EntryActionBarTemplate {
3189            id,
3190            read,
3191            starred,
3192            oob: true,
3193        }));
3194    }
3195
3196    let row = build_entry_row(pool, &did, id, None).await?;
3197    match row {
3198        Some(r) => Ok(render(&EntryRowTemplate { e: r })),
3199        None => Ok((StatusCode::NOT_FOUND, "entry not found").into_response()),
3200    }
3201}
3202
3203/// The feed URL for a cached feed id, if the row exists.
3204async fn feed_url_for_id(pool: &store::Pool, feed_id: i64) -> Option<String> {
3205    sqlx::query_scalar::<_, String>("SELECT url FROM feeds WHERE id = ?1")
3206        .bind(feed_id)
3207        .fetch_optional(pool)
3208        .await
3209        .ok()
3210        .flatten()
3211}
3212
3213// ---------------------------------------------------------------------------
3214// Mark-all-read
3215// ---------------------------------------------------------------------------
3216
3217/// Query for `POST /read-all` — an optional `?feed=<url>` scopes it to one feed;
3218/// absent means mark everything read.
3219#[derive(Debug, Deserialize, Default)]
3220struct ReadAllQuery {
3221    #[serde(default)]
3222    feed: Option<String>,
3223}
3224
3225/// `POST /read-all` — mark every entry read for the current DID, optionally
3226/// scoped to one feed (mark-all-read per feed or globally).
3227async fn mark_all_read(
3228    State(state): State<AppState>,
3229    headers: HeaderMap,
3230    Query(q): Query<ReadAllQuery>,
3231) -> Result<Response, WebError> {
3232    let did = match current_did(&state, &headers).await {
3233        Some(d) => d,
3234        None => return Ok(Redirect::to("/login").into_response()),
3235    };
3236    let pool = &state.db;
3237
3238    // Refresh the caller's `sub_ref` projection so the scoped mark-read writes
3239    // only ever touch feeds this DID actually subscribes to.
3240    resolve_subscriptions(&state, &did).await;
3241
3242    if let Some(feed_url) = q.feed.as_deref() {
3243        if let Ok(Some(feed)) = store::get_feed_by_url(pool, feed_url).await {
3244            store::mark_feed_read(pool, &did, feed.id, true).await?;
3245        }
3246        return Ok(Redirect::to(&format!("/?feed={}", qenc(feed_url))).into_response());
3247    }
3248
3249    // Global: mark every subscribed feed read. Fan out over the DID's feeds
3250    // (bounded by the per-DID subscription cap) using the batched per-feed path,
3251    // rather than one UPDATE round-trip per unread entry (unbounded) — same end
3252    // state, but O(feeds) statements instead of O(unread entries).
3253    for feed_id in store::subscribed_feed_ids(pool, &did).await? {
3254        store::mark_feed_read(pool, &did, feed_id, true).await?;
3255    }
3256    Ok(Redirect::to("/").into_response())
3257}
3258
3259// ---------------------------------------------------------------------------
3260// Subscribe by URL
3261// ---------------------------------------------------------------------------
3262
3263/// Flash for a URL this instance cannot store as a feed — not private, just
3264/// not a kind of feed it supports (an `at://` publication with
3265/// `FEATHERREADER_STANDARD_SITE` off, an unsupported scheme). Distinct from
3266/// [`PRIVATE_FEED_REFUSAL`], whose "not saved or sent anywhere" would be a
3267/// false promise for a record that may already exist in the user's PDS.
3268const UNSUPPORTED_FEED_URL_REFUSAL: &str =
3269    "That isn't a kind of feed this instance can subscribe to. Nothing was saved.";
3270
3271/// Shown when an OPML export is refused because the subscription list could not
3272/// be read in full.
3273///
3274/// **An empty export is worse than no export.** This path used to
3275/// `unwrap_or_default()`, so a failed read produced a 200 carrying a zero-feed
3276/// file — a blank backup, handed over at the moment the reader reached for one.
3277const EXPORT_INCOMPLETE_REFUSAL: &str =
3278    "Could not read your subscriptions in full, so nothing was exported. Your \
3279     feeds are unchanged — try again, and if it keeps failing the list may be \
3280     larger than this reader can page through.";
3281
3282/// Refusal message shown when a private/paid feed is submitted. FeatherReader
3283/// stores subscriptions in the user's PUBLIC PDS, so it supports public feeds
3284/// only for now — a private feed's secret URL is never saved, fetched, or sent
3285/// anywhere. Kept as a constant so the add and OPML paths share the exact wording
3286/// and the boot-smoke can assert on it.
3287const PRIVATE_FEED_REFUSAL: &str = "Private/paid feeds aren't supported yet. \
3288    FeatherReader stores your subscriptions in your public PDS, so it supports public \
3289    feeds for now — private-feed support arrives when atproto's private data \
3290    (permissioned records) ships. Your feed URL was not saved or sent anywhere.";
3291
3292/// Form body for `POST /subscriptions`.
3293#[derive(Debug, Deserialize)]
3294struct SubscribeForm {
3295    url: String,
3296    /// Optional folder `at://` URI to file the new feed under.
3297    #[serde(default)]
3298    folder: Option<String>,
3299}
3300
3301/// The DID-form URL to store for a pasted `at://` publication, or the flash
3302/// to refuse it with.
3303///
3304/// - The scheme is canonicalised: `At://` is the same publication, and
3305///   storing a second spelling makes a second row for it (#183).
3306/// - It must name a `site.standard.publication`; anything else is not a feed
3307///   this instance can read.
3308/// - A handle is resolved to its DID: a handle is a mutable name, and
3309///   `feeds.url` is keyed on identity, so only the DID form is stored.
3310async fn publication_url_from_paste(state: &AppState, input: &str) -> Result<String, String> {
3311    let unsupported = || UNSUPPORTED_FEED_URL_REFUSAL.to_string();
3312    let canonical = format!(
3313        "{}{}",
3314        crate::atproto::AT_URI_PREFIX,
3315        &input[crate::atproto::AT_URI_PREFIX.len()..]
3316    );
3317    let uri = crate::standard_site::AtUri::parse(&canonical).ok_or_else(unsupported)?;
3318    if uri.collection != lexicon::nsid::STANDARD_PUBLICATION {
3319        return Err(unsupported());
3320    }
3321    let did = if crate::oauth::identity::is_atproto_did(&uri.authority) {
3322        uri.authority.clone()
3323    } else {
3324        let handle =
3325            // Validated as a handle before it is sent anywhere: an authority
3326            // that is neither a DID nor a handle (`did:plc:TOOSHORT`, an
3327            // uppercase DID, a newline) is unsupported, not a lookup (found in
3328            // review).
3329            crate::oauth::identity::normalize_handle(&uri.authority).map_err(|_| unsupported())?;
3330        crate::atproto::resolve_handle(&state.http, &state.config.resolver_base, &handle)
3331            .await
3332            .map_err(|err| {
3333                warn!(%err, handle = %uri.authority, "could not resolve a pasted publication's handle");
3334                format!("Couldn't resolve the handle {} to an account.", uri.authority)
3335            })?
3336    };
3337    let url = format!(
3338        "{}{did}/{}/{}",
3339        crate::atproto::AT_URI_PREFIX,
3340        uri.collection,
3341        uri.rkey
3342    );
3343    if !feed::is_storable_feed_url(&url, true) {
3344        return Err(unsupported());
3345    }
3346    Ok(url)
3347}
3348
3349/// `POST /subscriptions` — subscribe by URL.
3350async fn add_subscription(
3351    State(state): State<AppState>,
3352    headers: HeaderMap,
3353    Form(form): Form<SubscribeForm>,
3354) -> Result<Response, WebError> {
3355    let did = match current_did(&state, &headers).await {
3356        Some(d) => d,
3357        None => return Ok(Redirect::to("/login").into_response()),
3358    };
3359    let pool = &state.db;
3360    let input = form.url.trim().to_string();
3361    if input.is_empty() {
3362        return Ok(Redirect::to("/").into_response());
3363    }
3364
3365    // Per-DID subscription cap: bound one account's storage/poller footprint on
3366    // the small box. Checked BEFORE any fetch/resolve so an over-cap account
3367    // can't even trigger an outbound request. `<= 0` disables the cap.
3368    let cap = state.config.max_subs_per_did;
3369    if cap > 0 {
3370        match store::count_subscriptions_for_did(pool, &did).await {
3371            Ok(n) if n >= cap => {
3372                info!(%did, current = n, cap, "refused subscribe: per-DID subscription cap reached");
3373                return Ok(Redirect::to(&format!(
3374                    "/?flash={}",
3375                    qenc(&format!(
3376                        "Subscription limit reached ({cap}). Remove a feed before adding another."
3377                    ))
3378                ))
3379                .into_response());
3380            }
3381            Ok(_) => {}
3382            Err(err) => warn!(%err, %did, "could not count subscriptions for cap check; allowing"),
3383        }
3384    }
3385
3386    // **An at:// paste is a standard.site publication, read by the poller
3387    // since 0.4.0**: canonicalised and resolved to its DID form here, then it
3388    // joins the ordinary path below. With the flag off it is refused as it
3389    // always was — the flag gates what may be stored.
3390    let is_at_uri = input
3391        .get(..crate::atproto::AT_URI_PREFIX.len())
3392        .is_some_and(|p| p.eq_ignore_ascii_case(crate::atproto::AT_URI_PREFIX));
3393    let publication_url = if is_at_uri {
3394        if !state.config.standard_site {
3395            info!(url = %input, %did, "refused an at:// paste: standard.site is off (not stored)");
3396            return Ok(
3397                Redirect::to(&format!("/?flash={}", qenc(UNSUPPORTED_FEED_URL_REFUSAL)))
3398                    .into_response(),
3399            );
3400        }
3401        match publication_url_from_paste(&state, &input).await {
3402            Ok(url) => Some(url),
3403            Err(flash) => {
3404                info!(url = %input, %did, %flash, "refused an at:// paste (not stored)");
3405                return Ok(Redirect::to(&format!("/?flash={}", qenc(&flash))).into_response());
3406            }
3407        }
3408    } else {
3409        None
3410    };
3411
3412    if let feed::FeedPrivacy::Private(reason) =
3413        feed::classify_feed_privacy(publication_url.as_deref().unwrap_or(&input))
3414    {
3415        info!(url = %input, %reason, %did, "refused private/paid feed at add (not fetched or stored)");
3416        return Ok(
3417            Redirect::to(&format!("/?flash={}", qenc(PRIVATE_FEED_REFUSAL))).into_response(),
3418        );
3419    }
3420
3421    let resolved = match publication_url {
3422        Some(url) => Ok(url),
3423        None => resolve_feed_url(&state.config, &input).await,
3424    };
3425    let feed_url = match resolved {
3426        Ok(u) => u,
3427        Err(err) => {
3428            warn!(%err, url = %input, "could not resolve a feed from the given URL");
3429            return Ok(Redirect::to(&format!(
3430                "/?flash={}",
3431                qenc("Couldn't find a feed at that URL")
3432            ))
3433            .into_response());
3434        }
3435    };
3436
3437    // Defensive: resolution may have discovered a feed URL that itself carries a
3438    // secret (e.g. a public site page linking a tokened feed). Re-check the
3439    // resolved URL and refuse before storing/writing anything.
3440    if let feed::FeedPrivacy::Private(reason) = feed::classify_feed_privacy(&feed_url) {
3441        info!(url = %feed_url, %reason, %did, "refused private/paid feed after resolution (not stored)");
3442        return Ok(
3443            Redirect::to(&format!("/?flash={}", qenc(PRIVATE_FEED_REFUSAL))).into_response(),
3444        );
3445    }
3446
3447    // The URL about to be STORED is what must be storable — not the one the
3448    // user typed. Autodiscovery already yields only http(s), but this is the
3449    // path that writes the row and the PDS record, so the check lives here too:
3450    // the same gate the OPML and rename paths apply, on the same terms.
3451    if !feed::is_storable_feed_url(&feed_url, state.config.standard_site) {
3452        info!(url = %feed_url, %did, "refused unsupported feed URL after resolution (not stored)");
3453        return Ok(
3454            Redirect::to(&format!("/?flash={}", qenc(UNSUPPORTED_FEED_URL_REFUSAL)))
3455                .into_response(),
3456        );
3457    }
3458
3459    // Global feeds ceiling: a brand-new distinct feed is refused once the shared
3460    // cache is full (an existing/duplicate feed URL is always fine — it adds no
3461    // row). Bounds total cache size across all users on the box. `<= 0` disables.
3462    let feeds_cap = state.config.max_feeds_global;
3463    if feeds_cap > 0 && store::get_feed_by_url(pool, &feed_url).await?.is_none() {
3464        match store::count_feeds(pool).await {
3465            Ok(n) if n >= feeds_cap => {
3466                warn!(%did, feeds = n, cap = feeds_cap, feed = %feed_url, "refused subscribe: global feeds ceiling reached");
3467                return Ok(Redirect::to(&format!(
3468                    "/?flash={}",
3469                    qenc(
3470                        "This instance is at its feed capacity right now. Please try again later."
3471                    )
3472                ))
3473                .into_response());
3474            }
3475            Ok(_) => {}
3476            Err(err) => warn!(%err, "could not count feeds for global-cap check; allowing"),
3477        }
3478    }
3479
3480    store::upsert_feed(
3481        pool,
3482        &store::NewFeed {
3483            url: feed_url.clone(),
3484            ..Default::default()
3485        },
3486    )
3487    .await?;
3488
3489    if let Ok(client) = feed::build_client() {
3490        if let Some(feed_row) = store::get_feed_by_url(pool, &feed_url).await? {
3491            match feed::poll_feed_by_kind(pool, &client, &state.config, &feed_row).await {
3492                Ok(outcome) => {
3493                    info!(feed = %feed_url, ?outcome, "polled new subscription");
3494                    // **This path is not the scheduler, so it must settle the
3495                    // error columns itself.** `poll_feed` writes validators and
3496                    // `last_polled` and nothing else.
3497                    feed::settle_poll(pool, &feed_url, &outcome, state.config.poll_interval).await;
3498                }
3499                Err(err) => warn!(%err, feed = %feed_url, "initial poll failed"),
3500            }
3501        }
3502    }
3503
3504    let mut sub = Subscription::new(feed_url.clone(), now_rfc3339());
3505    if let Ok(Some(feed_row)) = store::get_feed_by_url(pool, &feed_url).await {
3506        sub.title = feed_row.title.clone();
3507        sub.site_url = feed_row.site_url.clone();
3508    }
3509    sub.folder = form
3510        .folder
3511        .map(|f| f.trim().to_string())
3512        .filter(|f| !f.is_empty());
3513
3514    match state.repo().add_subscription(&did, &sub).await {
3515        Ok(rkey) => info!(feed = %feed_url, %rkey, %did, "wrote subscription record to PDS"),
3516        Err(err) => {
3517            warn!(%err, feed = %feed_url, %did, "PDS subscription write failed (cached locally)")
3518        }
3519    }
3520
3521    Ok(Redirect::to("/").into_response())
3522}
3523
3524/// `POST /subscriptions/:rkey/delete` — unsubscribe (delete the PDS record).
3525async fn delete_subscription(
3526    State(state): State<AppState>,
3527    headers: HeaderMap,
3528    Path(rkey): Path<String>,
3529) -> Result<Response, WebError> {
3530    let did = match current_did(&state, &headers).await {
3531        Some(d) => d,
3532        None => return Ok(Redirect::to("/login").into_response()),
3533    };
3534    match state.repo().remove_subscription(&did, &rkey).await {
3535        Ok(()) => info!(%did, %rkey, "unsubscribed (deleted PDS subscription record)"),
3536        Err(err) => warn!(%err, %did, %rkey, "PDS unsubscribe failed"),
3537    }
3538    Ok(Redirect::to("/").into_response())
3539}
3540
3541/// Form body for `POST /subscriptions/:rkey/rename`.
3542#[derive(Debug, Deserialize)]
3543struct RenameSubForm {
3544    url: String,
3545    #[serde(default)]
3546    title: Option<String>,
3547    #[serde(default)]
3548    site_url: Option<String>,
3549    #[serde(default)]
3550    folder: Option<String>,
3551}
3552
3553/// `POST /subscriptions/:rkey/rename` — retitle a feed and/or move it to a
3554/// folder, rewriting the whole subscription record via `putRecord`.
3555async fn rename_subscription(
3556    State(state): State<AppState>,
3557    headers: HeaderMap,
3558    Path(rkey): Path<String>,
3559    Form(form): Form<RenameSubForm>,
3560) -> Result<Response, WebError> {
3561    let did = match current_did(&state, &headers).await {
3562        Some(d) => d,
3563        None => return Ok(Redirect::to("/login").into_response()),
3564    };
3565    let feed_url = form.url.trim().to_string();
3566
3567    // Reject an empty/blank resolved URL — a rename with no usable URL must not
3568    // write a junk row to the cache or a malformed subscription record to the
3569    // PDS (add_subscription refuses an empty input the same way).
3570    if feed_url.is_empty() {
3571        return Ok(Redirect::to("/").into_response());
3572    }
3573
3574    // **Read before write — `update_subscription` is a `putRecord`, and a
3575    // putRecord replaces the WHOLE record** (see its doc on `atproto.rs`).
3576    //
3577    // This used to build a fresh `Subscription::new(feed_url, now_rfc3339())`
3578    // and hand that over, so every field the form does not carry was written
3579    // back as its default. `templates/manage_row.html` posts `url`, `title` and
3580    // `folder` — and nothing else — so a rename silently destroyed four fields:
3581    // `siteUrl`, `fetchHint`, `private`, and `createdAt`.
3582    //
3583    // `createdAt` is the one that matters most: it is the reader's subscribe
3584    // time, it is the sort key for "when did I subscribe", it lives in THEIR
3585    // repo rather than our cache, and once overwritten it is gone with nothing
3586    // in the UI to say so.
3587    //
3588    // There is no single-record read on `Repo` (no `getRecord`), so this lists
3589    // and filters. That is one extra round trip on an action that is already
3590    // doing a PDS write, and it is bounded; a `get_subscription` would be
3591    // strictly better if this ever measures badly.
3592    //
3593    // **A failed read refuses the rename.** Falling back to the old
3594    // rebuild-from-scratch here would reinstate the data loss on exactly the
3595    // flaky path, which is the worst place to have it. The write below already
3596    // takes this stance — "a failure here means nothing was renamed or moved" —
3597    // and the read gets the same one.
3598    let existing = match state.repo().list_subscriptions_sorted(&did).await {
3599        Ok(subs) => subs.into_iter().find(|(k, _)| *k == rkey).map(|(_, s)| s),
3600        Err(err) => {
3601            warn!(%err, %did, %rkey, "could not read the subscription before renaming it");
3602            return Ok(Redirect::to(&format!(
3603                "/?flash={}",
3604                qenc("Could not reach your PDS — nothing was renamed or moved.")
3605            ))
3606            .into_response());
3607        }
3608    };
3609    let Some(existing) = existing else {
3610        // The rkey is not in the reader's repo. Renaming a record that is not
3611        // there would CREATE one, which is not what "rename" means and would
3612        // give it a fresh `createdAt` — the bug this read exists to prevent.
3613        warn!(%did, %rkey, "refused rename: no such subscription in the repo");
3614        return Ok(Redirect::to(&format!(
3615            "/?flash={}",
3616            qenc("That subscription is no longer in your repo — nothing was renamed or moved.")
3617        ))
3618        .into_response());
3619    };
3620
3621    // The subscription can be repointed at a different feed URL. **Every gate
3622    // on the URL applies to a repoint and only a repoint** — the three below
3623    // were each, at one time, run before this line on the URL as posted, and
3624    // each refused a pure retitle of a record that already existed:
3625    //
3626    // - privacy: the narrowed at:// arm fails closed as `Private` for an
3627    //   at-URI that is not a publication (a feed generator another client
3628    //   subscribed to), so the record became un-editable with a flash saying
3629    //   it "was not saved or sent anywhere";
3630    // - the global feeds ceiling keyed on "URL not in the cache", and an
3631    //   at:// record is never cached with the flag off, so at capacity a
3632    //   retitle was refused for a row the handler would not insert;
3633    // - storability, the same way.
3634    //
3635    // An unchanged URL is already in the reader's repo; refusing to retitle
3636    // it protects nothing and takes their own record away from them.
3637    // Like for like: the form value is trimmed, and a record another client
3638    // wrote may carry padding — compared raw, every retitle of it was a repoint.
3639    let url_changed = existing.url.trim() != feed_url;
3640
3641    // **Storability, on the same terms as the add and OPML paths — for a
3642    // REPOINT, and FIRST.** A target this instance cannot store gets that
3643    // answer, not "private" (the at:// arm fails closed) or "at capacity"
3644    // (it would never be inserted) — the ordering the add path has. This handler writes `feeds` via `upsert_feed` and had only the
3645    // privacy check above, so `FEATHERREADER_STANDARD_SITE` was bypassable
3646    // here; a review found it by enumerating every writer of the table. The
3647    // first fix ran this check before the repo lookup, on the URL as posted —
3648    // which refused a pure retitle of a subscription that already IS an
3649    // at-URI, on every instance with the flag off. The flag gates what the
3650    // cache may store, not whether a reader may edit their own record: an
3651    // unchanged non-storable URL keeps its PDS write and simply gets no cache
3652    // row below.
3653    let storable = feed::is_storable_feed_url(&feed_url, state.config.standard_site);
3654    if url_changed && !storable {
3655        info!(url = %feed_url, %did, %rkey, "refused a repoint to a non-storable feed URL");
3656        return Ok(
3657            Redirect::to(&format!("/?flash={}", qenc(UNSUPPORTED_FEED_URL_REFUSAL)))
3658                .into_response(),
3659        );
3660    }
3661
3662    // Block private/paid feeds on a repoint. `url` is attacker-controllable,
3663    // and rename both upserts it to the local cache AND rewrites the PDS
3664    // subscription record (a public `putRecord`), so without this guard a
3665    // crafted rename could land a secret-bearing URL in the public PDS — the
3666    // exact leak the add and OPML paths already prevent.
3667    if url_changed {
3668        if let feed::FeedPrivacy::Private(reason) = feed::classify_feed_privacy(&feed_url) {
3669            info!(url = %feed_url, %reason, %did, %rkey, "refused private/paid feed at rename (not stored or written)");
3670            return Ok(
3671                Redirect::to(&format!("/?flash={}", qenc(PRIVATE_FEED_REFUSAL))).into_response(),
3672            );
3673        }
3674    }
3675
3676    // Global feeds ceiling parity with add_subscription: a repoint to a
3677    // brand-new feed URL would insert a NEW `feeds` row. Refuse that when the
3678    // shared cache is at capacity (an existing/duplicate URL adds no row and
3679    // is always fine). `<= 0` disables.
3680    let feeds_cap = state.config.max_feeds_global;
3681    if url_changed
3682        && feeds_cap > 0
3683        && store::get_feed_by_url(&state.db, &feed_url)
3684            .await?
3685            .is_none()
3686    {
3687        match store::count_feeds(&state.db).await {
3688            Ok(n) if n >= feeds_cap => {
3689                warn!(%did, %rkey, feeds = n, cap = feeds_cap, feed = %feed_url, "refused rename: global feeds ceiling reached");
3690                return Ok(Redirect::to(&format!(
3691                    "/?flash={}",
3692                    qenc(
3693                        "This instance is at its feed capacity right now. Please try again later."
3694                    )
3695                ))
3696                .into_response());
3697            }
3698            Ok(_) => {}
3699            Err(err) => warn!(%err, "could not count feeds for global-cap check; allowing"),
3700        }
3701    }
3702
3703    let mut sub = existing;
3704    sub.url = feed_url;
3705    sub.title = form
3706        .title
3707        .map(|t| t.trim().to_string())
3708        .filter(|t| !t.is_empty());
3709    sub.folder = form
3710        .folder
3711        .map(|f| f.trim().to_string())
3712        .filter(|f| !f.is_empty());
3713    // `createdAt` and `private` carry over untouched — neither is a property of
3714    // which feed URL the subscription points at.
3715    //
3716    // `siteUrl` and `fetchHint` ARE properties of the specific feed, so a
3717    // repoint drops them rather than leaving a site link for the old feed
3718    // hanging off the new one. An explicit form value still wins if the form
3719    // ever starts carrying one.
3720    match form
3721        .site_url
3722        .map(|t| t.trim().to_string())
3723        .filter(|t| !t.is_empty())
3724    {
3725        Some(site) => sub.site_url = Some(site),
3726        None if url_changed => sub.site_url = None,
3727        None => {}
3728    }
3729    if url_changed {
3730        sub.fetch_hint = None;
3731    }
3732
3733    // Keep the local cache title in step for the loose-feed fallback path —
3734    // for a row this instance would have. Two cases write nothing:
3735    //
3736    // - not storable (an existing at-URI with the flag off): the record is the
3737    //   reader's to edit, the cache row is not this instance's to create;
3738    // - an unchanged URL with no cache row: a retitle is never the write that
3739    //   CREATES a row. That covers two findings at once — the ceiling is
3740    //   checked on a repoint only, so a retitle must not insert past it; and
3741    //   a secret-bearing URL another client subscribed to has no row (the
3742    //   privacy gate above runs on a repoint only, and `resolve_subscriptions`
3743    //   refuses to cache it), so it cannot enter the shared table here, be
3744    //   polled, fail, and be printed on the admin page. A privacy re-check on
3745    //   this write was the first draft; mutation showed it dead — the row
3746    //   rule already refused every case it would have.
3747    let cache_write =
3748        storable && (url_changed || store::get_feed_by_url(&state.db, &sub.url).await?.is_some());
3749    if !cache_write {
3750        info!(%did, %rkey, url = %sub.url, "renamed a subscription without touching the cache");
3751    } else if let Err(err) = store::upsert_feed(
3752        &state.db,
3753        &store::NewFeed {
3754            url: sub.url.clone(),
3755            title: sub.title.clone(),
3756            site_url: sub.site_url.clone(),
3757            ..Default::default()
3758        },
3759    )
3760    .await
3761    {
3762        // Not fatal to the rename — the PDS record below is the source of truth
3763        // — but a missing `feeds` row means this subscription is never polled.
3764        warn!(%err, %did, url = %sub.url, "could not update the cached feed row on rename");
3765    }
3766
3767    // **The PDS write decides what the reader is told.**
3768    //
3769    // This used to `warn!` on failure and then redirect exactly as it does on
3770    // success, so a rename that did not happen was indistinguishable from one
3771    // that did — the reader saw their old title come back and had no reason to
3772    // think anything had gone wrong. The PDS record IS the subscription; a
3773    // failure here means nothing was renamed or moved.
3774    match state.repo().update_subscription(&did, &rkey, &sub).await {
3775        Ok(res) => {
3776            info!(%did, %rkey, uri = %res.uri, "renamed/moved subscription");
3777            Ok(Redirect::to("/").into_response())
3778        }
3779        Err(err) => {
3780            warn!(%err, %did, %rkey, "PDS subscription update failed");
3781            Ok(Redirect::to(&format!(
3782                "/?flash={}",
3783                qenc("Could not save that change to your PDS — nothing was renamed or moved.")
3784            ))
3785            .into_response())
3786        }
3787    }
3788}
3789
3790// ---------------------------------------------------------------------------
3791// Folders
3792// ---------------------------------------------------------------------------
3793
3794/// Form body for `POST /folders`.
3795#[derive(Debug, Deserialize)]
3796struct FolderForm {
3797    name: String,
3798}
3799
3800/// `POST /folders` — create a folder record.
3801async fn create_folder(
3802    State(state): State<AppState>,
3803    headers: HeaderMap,
3804    Form(form): Form<FolderForm>,
3805) -> Result<Response, WebError> {
3806    let did = match current_did(&state, &headers).await {
3807        Some(d) => d,
3808        None => return Ok(Redirect::to("/login").into_response()),
3809    };
3810    let name = form.name.trim();
3811    if name.is_empty() {
3812        return Ok(Redirect::to("/").into_response());
3813    }
3814    let folder = Folder::new(name.to_string(), now_rfc3339());
3815    match state.repo().add_folder(&did, &folder).await {
3816        Ok(rkey) => info!(%did, %rkey, name, "created folder record"),
3817        Err(err) => warn!(%err, %did, "PDS folder create failed"),
3818    }
3819    Ok(Redirect::to("/").into_response())
3820}
3821
3822/// `POST /folders/:rkey/rename` — rename a folder record.
3823async fn rename_folder(
3824    State(state): State<AppState>,
3825    headers: HeaderMap,
3826    Path(rkey): Path<String>,
3827    Form(form): Form<FolderForm>,
3828) -> Result<Response, WebError> {
3829    let did = match current_did(&state, &headers).await {
3830        Some(d) => d,
3831        None => return Ok(Redirect::to("/login").into_response()),
3832    };
3833    let name = form.name.trim();
3834    if name.is_empty() {
3835        return Ok(Redirect::to("/").into_response());
3836    }
3837    let folder = Folder::new(name.to_string(), now_rfc3339());
3838    match state.repo().rename_folder(&did, &rkey, &folder).await {
3839        Ok(res) => info!(%did, %rkey, uri = %res.uri, "renamed folder"),
3840        Err(err) => warn!(%err, %did, %rkey, "PDS folder rename failed"),
3841    }
3842    Ok(Redirect::to("/").into_response())
3843}
3844
3845/// `POST /folders/:rkey/delete` — delete a folder record (feeds referencing it
3846/// simply become un-foldered).
3847async fn delete_folder(
3848    State(state): State<AppState>,
3849    headers: HeaderMap,
3850    Path(rkey): Path<String>,
3851) -> Result<Response, WebError> {
3852    let did = match current_did(&state, &headers).await {
3853        Some(d) => d,
3854        None => return Ok(Redirect::to("/login").into_response()),
3855    };
3856    match state.repo().remove_folder(&did, &rkey).await {
3857        Ok(()) => info!(%did, %rkey, "deleted folder record"),
3858        Err(err) => warn!(%err, %did, %rkey, "PDS folder delete failed"),
3859    }
3860    Ok(Redirect::to("/").into_response())
3861}
3862
3863/// Resolve a user-pasted URL to a canonical feed URL: if fetching it yields a
3864/// feed document we take it as-is; if it yields an HTML page we run
3865/// autodiscovery over its `<link rel="alternate">` tags.
3866async fn resolve_feed_url(_config: &Config, input: &str) -> anyhow::Result<String> {
3867    let parsed =
3868        url::Url::parse(input).map_err(|e| anyhow::anyhow!("not a valid URL {input:?}: {e}"))?;
3869
3870    let client = feed::build_client()?;
3871    // Fetch through the SSRF guard: scheme + resolved-IP checks on the URL and
3872    // every redirect hop, so a user-pasted URL can't reach cloud metadata /
3873    // loopback / private hosts.
3874    let resp = crate::net::guarded_get(&client, parsed.as_str(), &[]).await?;
3875    let final_url = resp.url().clone();
3876    let content_type = resp
3877        .headers()
3878        .get(axum::http::header::CONTENT_TYPE)
3879        .and_then(|v| v.to_str().ok())
3880        .unwrap_or("")
3881        .to_ascii_lowercase();
3882    // Cap the body (streamed, aborts over 8 MiB) — never trust Content-Length,
3883    // gzip strips it, and this response is reflected into the UI.
3884    let raw = crate::net::read_capped(resp).await?;
3885    let body = String::from_utf8_lossy(&raw).into_owned();
3886
3887    let looks_like_feed = content_type.contains("xml")
3888        || content_type.contains("rss")
3889        || content_type.contains("atom")
3890        || content_type.contains("application/feed+json")
3891        || {
3892            let head = body.trim_start();
3893            head.starts_with("<?xml")
3894                || head.starts_with("<rss")
3895                || head.starts_with("<feed")
3896                || head.contains("<rss")
3897                || head.contains("<feed")
3898        };
3899    if looks_like_feed {
3900        return Ok(final_url.to_string());
3901    }
3902
3903    match feed::discover_feed(&body, Some(&final_url)) {
3904        Some(u) => Ok(u.to_string()),
3905        None => anyhow::bail!("no feed found at {input} (no autodiscovery link)"),
3906    }
3907}
3908
3909// ---------------------------------------------------------------------------
3910// Login (atproto OAuth via the sidecar)
3911// ---------------------------------------------------------------------------
3912
3913/// Query for `GET /login`.
3914#[derive(Debug, Deserialize, Default)]
3915struct LoginQuery {
3916    #[serde(default)]
3917    handle: Option<String>,
3918    #[serde(default)]
3919    error: Option<String>,
3920    #[serde(default)]
3921    flash: Option<String>,
3922}
3923
3924/// `GET /login` — start the atproto OAuth flow, or render the handle form.
3925///
3926/// **Pre-handshake gate:** starting OAuth (a `?handle=` GET) is refused unless
3927/// the visitor is allowed by [`may_start_oauth`] — an existing beta seat (via
3928/// session cookie *or* the submitted handle resolving to a seated DID) or a
3929/// valid reserving invite cookie. Refusal redirects to `/beta/redeem`. The bare
3930/// form (no handle) always renders.
3931async fn login_form(
3932    State(state): State<AppState>,
3933    headers: HeaderMap,
3934    Query(q): Query<LoginQuery>,
3935) -> Response {
3936    if let Some(handle) = q
3937        .handle
3938        .map(|h| h.trim().to_string())
3939        .filter(|h| !h.is_empty())
3940    {
3941        if !may_start_oauth(&state, &headers, &handle).await {
3942            return Redirect::to("/beta/redeem").into_response();
3943        }
3944        return start_oauth(&state, &handle).await;
3945    }
3946    render(&LoginTemplate {
3947        card: login_card(&state.config),
3948        repo_url: REPO_URL,
3949        error: q.error.unwrap_or_default(),
3950        flash: q.flash.unwrap_or_default(),
3951    })
3952}
3953
3954/// `POST /login` — the handle-form submit: redirect into the sidecar OAuth flow.
3955/// Subject to the same pre-handshake invite gate as `GET /login?handle=`.
3956async fn login_submit(
3957    State(state): State<AppState>,
3958    headers: HeaderMap,
3959    Form(form): Form<LoginForm>,
3960) -> Response {
3961    let handle = form.handle.trim();
3962    if handle.is_empty() {
3963        return login_error(&state, "Enter your atproto handle.");
3964    }
3965    if !may_start_oauth(&state, &headers, handle).await {
3966        return Redirect::to("/beta/redeem").into_response();
3967    }
3968    start_oauth(&state, handle).await
3969}
3970
3971/// Whether this visitor is allowed to *start* the OAuth handshake. The gate
3972/// admits, in order of cost:
3973///
3974/// 1. an existing beta member's cookie session whose DID already holds a seat;
3975/// 2. a fresh visitor carrying a valid reserving invite cookie;
3976/// 3. a cookie-less visitor whose submitted `handle` resolves to a DID that
3977///    already holds a seat — this honors the **seeded admin's first login** on a
3978///    fresh deploy (and any returning member who cleared cookies) without a
3979///    session cookie or an invite code.
3980///
3981/// The cookie/invite fast paths run FIRST and short-circuit, so the network
3982/// handle→DID resolution is only attempted when neither applies. It fails
3983/// CLOSED: a malformed/unresolvable handle, a resolution error/timeout, or a
3984/// resolved DID with no seat all leave the visitor bounced to `/beta/redeem`.
3985/// This keeps the anti-abuse intent — a rando now pays a cheap handle
3986/// resolution instead of a burned sidecar handshake (and `/login` is already in
3987/// the rate-limited path set).
3988async fn may_start_oauth(state: &AppState, headers: &HeaderMap, handle: &str) -> bool {
3989    // The production resolver is the app's existing atproto handle→DID path,
3990    // routed through the SSRF guard. Resolution is injected so tests can exercise
3991    // the gate without a live network call (the guard forbids loopback mocks).
3992    may_start_oauth_with(state, headers, handle, |h| async move {
3993        crate::atproto::resolve_handle(&state.http, &state.config.resolver_base, &h)
3994            .await
3995            .ok()
3996    })
3997    .await
3998}
3999
4000/// Core of [`may_start_oauth`] with the handle→DID resolver injected as `resolve`
4001/// (returning `Some(did)` on success, `None` on any failure/unresolvable handle).
4002/// The cookie + invite fast paths run FIRST and short-circuit, so `resolve` is
4003/// only called when neither admits — keeping the network round-trip off the hot
4004/// path and preserving the fail-closed contract on resolution failure.
4005async fn may_start_oauth_with<F, Fut>(
4006    state: &AppState,
4007    headers: &HeaderMap,
4008    handle: &str,
4009    resolve: F,
4010) -> bool
4011where
4012    F: FnOnce(String) -> Fut,
4013    Fut: std::future::Future<Output = Option<String>>,
4014{
4015    // 1. An already-beta'd session may re-auth freely.
4016    if let Some(did) = current_did(state, headers).await {
4017        if store::has_beta_access(&state.db, &did)
4018            .await
4019            .unwrap_or(false)
4020        {
4021            return true;
4022        }
4023    }
4024    // 2. A valid reserving invite cookie.
4025    if invite_cookie_code(headers, &state.config.cookie_secret).is_some() {
4026        return true;
4027    }
4028    // 3. Cookie-less: honor an existing seat by resolving the submitted handle to
4029    //    a DID (the seeded-admin first-login / cleared-cookies case). Fail closed
4030    //    on any resolution error or unresolvable/malformed handle.
4031    match resolve(handle.to_string()).await {
4032        Some(did) => store::has_beta_access(&state.db, &did)
4033            .await
4034            .unwrap_or(false),
4035        None => {
4036            warn!(%handle, "handle resolution failed in pre-handshake beta gate");
4037            false
4038        }
4039    }
4040}
4041
4042/// Begin the OAuth handshake for `handle`, on whichever backend is live.
4043///
4044/// **On `form-action 'self'` and this redirect.** The Rust arm answers a form
4045/// POST with a redirect straight to the PDS — cross-origin — while the app's CSP
4046/// carries `form-action 'self'`. Browsers have historically disagreed about
4047/// whether that directive applies to redirects following a form submission, and
4048/// if it did here, login would break in a browser while every test passed.
4049///
4050/// It does not, and the evidence is the SIDECAR path, which is live in
4051/// production today: `POST /login` -> 303 to the same-origin `/oauth/login` ->
4052/// 302 to the PDS, cross-origin, under this same CSP. A browser checking the
4053/// whole redirect chain would already be blocking that. One checking only the
4054/// form's action URL sees `/login` in both cases. The two arms differ only in
4055/// how many same-origin hops precede the cross-origin one, so any policy that
4056/// permits the sidecar flow permits this one.
4057///
4058/// The two arms differ in SHAPE, not just in implementation. The sidecar owns
4059/// its own `/login` and its own callback, so starting a login is one redirect
4060/// and nothing is stored here. The Rust backend pushes the authorization
4061/// request itself, which means this app now holds the pending login — and must
4062/// set the browser-binding cookie that the callback will be checked against.
4063async fn start_oauth(state: &AppState, handle: &str) -> Response {
4064    match state.config.repo_backend {
4065        crate::metrics::Backend::Sidecar => {
4066            let url = state.sidecar.login_url(handle, None);
4067            info!(%handle, "redirecting to OAuth sidecar login");
4068            Redirect::to(&url).into_response()
4069        }
4070        crate::metrics::Backend::Rust => {
4071            let Some(runtime) = state.oauth.as_deref() else {
4072                warn!("the rust backend is live but its OAuth runtime is absent");
4073                return login_error(state, "Login is not available right now.");
4074            };
4075            match crate::oauth::login::start(
4076                runtime,
4077                &state.http,
4078                &state.db,
4079                handle,
4080                crate::store::now_unix(),
4081            )
4082            .await
4083            {
4084                Ok(started) => {
4085                    info!(%handle, "pushed authorization request; redirecting to the PDS");
4086                    let mut resp = Redirect::to(&started.authorize_url).into_response();
4087                    set_cookie(
4088                        &mut resp,
4089                        &cookie::sign_value(
4090                            OAUTH_BINDING_COOKIE,
4091                            &started.binding_token,
4092                            &state.config.cookie_secret,
4093                            OAUTH_BINDING_MAX_AGE_SECS,
4094                        ),
4095                    );
4096                    resp
4097                }
4098                Err(err) => {
4099                    // The handle the user typed is logged; the error is not shown
4100                    // to them verbatim, since it can name internal hosts.
4101                    warn!(%err, %handle, "could not start the OAuth login");
4102                    login_error(state, "Could not start login for that handle.")
4103                }
4104            }
4105        }
4106    }
4107}
4108
4109/// Clear the browser-binding cookie. Called on every terminal outcome of a
4110/// callback, successful or not: the pending row is consumed either way, so a
4111/// lingering cookie can only ever match a login that no longer exists.
4112fn clear_binding_cookie(resp: &mut Response) {
4113    set_cookie(
4114        resp,
4115        &format!("{OAUTH_BINDING_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
4116    );
4117}
4118
4119/// Form body for `POST /login`.
4120#[derive(Debug, Deserialize)]
4121struct LoginForm {
4122    handle: String,
4123}
4124
4125/// Query for `GET /oauth/callback`.
4126///
4127/// Carries BOTH shapes, because the two backends deliver different things to
4128/// the same URL: the sidecar hands back a one-shot `session_id` it has already
4129/// exchanged, while the PDS redirects here directly with `code`/`state`/`iss`
4130/// for this app to exchange itself. Which fields are populated is decided by
4131/// which backend started the login, not by which is live now — so a flip with a
4132/// login already in flight still lands in the right arm.
4133#[derive(Debug, Deserialize, Default)]
4134struct CallbackQuery {
4135    /// Sidecar backend: the handoff id.
4136    #[serde(default)]
4137    session_id: Option<String>,
4138    /// Rust backend: the authorization code and its envelope.
4139    #[serde(default)]
4140    code: Option<String>,
4141    #[serde(default)]
4142    state: Option<String>,
4143    #[serde(default)]
4144    iss: Option<String>,
4145    /// JARM, which is not supported — carried only so it can be refused
4146    /// explicitly rather than read as "no code".
4147    #[serde(default)]
4148    response: Option<String>,
4149    #[serde(default)]
4150    error: Option<String>,
4151    #[serde(default)]
4152    error_description: Option<String>,
4153}
4154
4155/// `GET /oauth/callback` — establish the cookie session.
4156///
4157/// **Invite gate:** the verified DID must hold beta access. If it already does
4158/// (existing member / seeded admin) it's admitted directly. Otherwise we bind
4159/// the DID to the reserved invite cookie: `redeem_code` atomically consumes the
4160/// code and grants the seat. A DID with neither is bounced to `/beta/redeem`.
4161async fn oauth_callback(
4162    State(state): State<AppState>,
4163    headers: HeaderMap,
4164    Query(q): Query<CallbackQuery>,
4165) -> Response {
4166    // An error response is handled by the SAME arm that would have handled a
4167    // success, not short-circuited here.
4168    //
4169    // Returning early looks obviously right and is wrong on the Rust path: it
4170    // skips `verify_callback`, which validates `iss` BEFORE reporting the error
4171    // precisely because RFC 9207 §2.4 says a client "MUST NOT assume that the
4172    // error originates from the intended AS". It also leaves the pending row
4173    // unconsumed, so a `state` that has already produced a callback stays usable
4174    // until it expires.
4175    //
4176    // The sidecar arm has no such check to reach, so it is short-circuited
4177    // below, preserving exactly what it did before.
4178    // **The arm is chosen by what the SERVER knows, not by what the caller
4179    // sent.** A `session_id` in the query used to select the sidecar arm on its
4180    // own — so a caller could pick which code path ran, and the sidecar arm has
4181    // no browser-binding check at all. It also short-circuited the error path
4182    // below, skipping the `iss` validation.
4183    //
4184    // Requiring the Rust runtime to be absent, or a sidecar backend to be the
4185    // configured one, means the selection follows this deployment's own
4186    // configuration. A login started before a flip still completes, because the
4187    // Rust arm is reached whenever the Rust runtime exists and can match the
4188    // `state` against a pending row it actually wrote.
4189    // The sidecar hands off in TWO shapes, not one: `?session_id=…` on success
4190    // and `?error=…&error_description=…` on its own failure. Keying only on
4191    // `session_id` sent the failure shape down the Rust arm, which then failed
4192    // with "no `state`" and replaced the specific reason with a generic one —
4193    // and `error_description` is exactly what the sidecar Caddy routing matches
4194    // to send that request here in the first place.
4195    let sidecar_shape =
4196        q.session_id.as_deref().is_some_and(|s| !s.is_empty()) || q.error_description.is_some();
4197    let sidecar_handoff = sidecar_shape
4198        && (state.oauth.is_none() || state.config.repo_backend == crate::metrics::Backend::Sidecar);
4199    if let Some(err) = q.error.clone() {
4200        // **Neither the code nor the description is echoed as sent.**
4201        //
4202        // Both are server-controlled free text arriving on a public GET, so
4203        // anyone who can make a browser fetch this URL chooses them. The raw
4204        // `error` used to go into a `warn!` AND into the rendered login page,
4205        // and `error_description` — arbitrary text, newlines included — went
4206        // into the log verbatim: a log-injection surface on one side and
4207        // attacker-chosen copy in the product's own voice on the other.
4208        //
4209        // `oauth::flow` already decided this exact question for the Rust arm:
4210        // reduce the code to a known slug, drop the description entirely. That
4211        // reasoning is not specific to which arm handles the callback, and this
4212        // one simply never got the same treatment. The description's LENGTH is
4213        // kept, because "the server sent a 4 KB explanation" is occasionally
4214        // worth knowing and cannot be used to inject anything.
4215        let slug = crate::oauth::flow::known_error_slug(&err);
4216        warn!(
4217            error = slug,
4218            desc_len = q.error_description.as_deref().map_or(0, str::len),
4219            "OAuth callback returned an error"
4220        );
4221        if sidecar_handoff || state.oauth.is_none() {
4222            return login_error(&state, &format!("Login failed: {slug}"));
4223        }
4224        // Fall through: the Rust arm consumes the pending row and validates
4225        // `iss` against it, and reports the failure afterwards.
4226    }
4227
4228    // Which arm runs is decided by WHAT ARRIVED, not by which backend is
4229    // currently selected: a login started before a flip must still complete.
4230    let session = if sidecar_handoff {
4231        let session_id = q.session_id.clone().unwrap_or_default();
4232        match state.sidecar.resolve_session(&session_id).await {
4233            Ok(Some(s)) => s,
4234            Ok(None) => {
4235                warn!("OAuth callback session_id did not resolve (expired/unknown)");
4236                return login_error(&state, "Login session expired — please try again.");
4237            }
4238            Err(err) => {
4239                warn!(%err, "failed to resolve OAuth session via the sidecar");
4240                return login_error(&state, "Login failed talking to the auth service.");
4241            }
4242        }
4243    } else {
4244        let Some(runtime) = state.oauth.as_deref() else {
4245            warn!("an OAuth callback arrived with no sidecar session and no Rust runtime");
4246            return login_error(&state, "Login failed: this login could not be completed.");
4247        };
4248        let params = crate::oauth::flow::CallbackParams {
4249            code: q.code.clone(),
4250            state: q.state.clone(),
4251            iss: q.iss.clone(),
4252            // Passed through, NOT dropped: `verify_callback` checks `iss`
4253            // against the pending row's issuer before it reports the error, and
4254            // it cannot do that for an error it never sees.
4255            error: q.error.clone(),
4256            error_description: q.error_description.clone(),
4257            response: q.response.clone(),
4258        };
4259        let binding =
4260            cookie::verify_value(&headers, OAUTH_BINDING_COOKIE, &state.config.cookie_secret);
4261        match crate::oauth::login::complete(
4262            runtime,
4263            &state.http,
4264            &state.db,
4265            &params,
4266            binding.as_deref(),
4267            crate::store::now_unix(),
4268        )
4269        .await
4270        {
4271            Ok(done) => crate::atproto::SidecarSession {
4272                did: done.did,
4273                handle: done.handle,
4274            },
4275            Err(err) => {
4276                // Never echoed to the browser: the message can name the issuer,
4277                // the PDS, and why a binding check failed.
4278                warn!(%err, "could not complete the OAuth callback");
4279                let mut resp = login_error(&state, "Login failed — please try again.");
4280                clear_binding_cookie(&mut resp);
4281                return resp;
4282            }
4283        }
4284    };
4285
4286    // Bind the verified DID to the invite gate. Returns a response only on the
4287    // (rare) failure paths; `Ok(())` means the DID now holds beta access.
4288    let mut clear_invite = false;
4289    if !store::has_beta_access(&state.db, &session.did)
4290        .await
4291        .unwrap_or(false)
4292    {
4293        // Not yet a member: consume the reserved invite code, if any.
4294        let code = match invite_cookie_code(&headers, &state.config.cookie_secret) {
4295            Some(c) => c,
4296            None => {
4297                warn!(did = %session.did, "OAuth callback with no beta access and no invite cookie");
4298                return Redirect::to("/beta/redeem").into_response();
4299            }
4300        };
4301        match store::redeem_code(
4302            &state.db,
4303            &code,
4304            &session.did,
4305            session.handle.as_deref(),
4306            state.config.beta_cap,
4307        )
4308        .await
4309        {
4310            Ok(Ok(())) => {
4311                clear_invite = true;
4312                info!(did = %session.did, "invite code redeemed at OAuth callback; beta access granted");
4313            }
4314            Ok(Err(policy)) => {
4315                warn!(did = %session.did, ?policy, "invite redeem failed at callback");
4316                let mut resp = redeem_bounce(&state, &policy).into_response();
4317                // The reservation is spent/invalid — drop the stale invite cookie.
4318                clear_invite_cookie(&mut resp);
4319                return resp;
4320            }
4321            Err(err) => {
4322                warn!(%err, did = %session.did, "invite redeem infra error at callback");
4323                return login_error(&state, "Login failed while confirming your invite.");
4324            }
4325        }
4326    }
4327
4328    // Mint an opaque, random server-side session id and store the identity under
4329    // it; the cookie carries the (HMAC-signed) sid, never the DID.
4330    let sid = state.sessions.create(Session {
4331        did: session.did.clone(),
4332        handle: session.handle.clone(),
4333    });
4334    let cookie = cookie::sign_session(&sid, &state.config.cookie_secret);
4335    info!(did = %session.did, handle = ?session.handle, "OAuth login OK; session cookie set");
4336
4337    let mut resp = Redirect::to("/").into_response();
4338    set_cookie(&mut resp, &cookie);
4339    clear_binding_cookie(&mut resp);
4340    if clear_invite {
4341        clear_invite_cookie(&mut resp);
4342    }
4343    resp
4344}
4345
4346/// Revoke a DID's OAuth session on BOTH backends, best-effort.
4347///
4348/// Not "whichever backend is live": during a cutover a user's tokens can be in
4349/// either store — they logged in under one backend and are logging out under
4350/// the other. Revoking only the live one would leave a live refresh token
4351/// behind in the other, which is the exact failure sign-out exists to prevent,
4352/// and it would be invisible because the sign-out itself looks successful.
4353///
4354/// Both arms are best-effort. The caller has already decided to sign the user
4355/// out, and a network failure must not trap them in a half-logged-out state.
4356/// How long sign-out will wait for a final read-state flush before revoking
4357/// anyway.
4358///
4359/// Bounded because the flush talks to the user's PDS, and a user trying to leave
4360/// must never be held by a server that is not answering. Three seconds is long
4361/// enough for a healthy `applyWrites` (the production samples run 100–970 ms)
4362/// and short enough that a dead PDS is an inconvenience rather than a trap.
4363const SIGN_OUT_FLUSH_BUDGET: std::time::Duration = std::time::Duration::from_secs(3);
4364
4365/// Flush whatever read-state is still dirty for `did`, then give up quietly.
4366///
4367/// **Called before revoking, because revoking first strands it (#117).**
4368/// `revoke_everywhere` deletes the OAuth session, and a dirty cursor with no
4369/// session cannot be sent by anyone — it parks until the user signs in again,
4370/// which may be never. Flushing first is what stops the common case from
4371/// becoming that.
4372///
4373/// Best-effort by construction: every failure path here falls through to the
4374/// revoke. A flush that times out or errors leaves the cursors dirty, which is
4375/// the parked state the flusher now handles deliberately rather than retrying
4376/// forever.
4377async fn flush_before_revoke(state: &AppState, did: &str) {
4378    match tokio::time::timeout(
4379        SIGN_OUT_FLUSH_BUDGET,
4380        crate::readstate::flush_did(state, did),
4381    )
4382    .await
4383    {
4384        Ok(Ok(())) => {}
4385        Ok(Err(err)) => {
4386            warn!(%did, %err, "sign-out: final read-state flush failed; it will park until next sign-in")
4387        }
4388        Err(_) => warn!(
4389            %did,
4390            budget = ?SIGN_OUT_FLUSH_BUDGET,
4391            "sign-out: final read-state flush timed out; it will park until next sign-in"
4392        ),
4393    }
4394}
4395
4396async fn revoke_everywhere(state: &AppState, did: &str) {
4397    // **Counted under Backend::Sidecar, not left uncounted.** A review found
4398    // that recording only the rust arm let `oauth_revoke` report a clean success
4399    // while every sidecar revocation failed — and for anyone who logged in before
4400    // the cutover, the sidecar store is the ONLY one that held tokens, so the
4401    // rust arm correctly returns NoSession and the metric reads all-clear while
4402    // live refresh tokens sit at the PDS.
4403    //
4404    // Same op name, different backend: the backend column is what distinguishes
4405    // them, so "no revocation failures" means checking both rows, not one.
4406    let sidecar_started = std::time::Instant::now();
4407    let sidecar_ok = match state.sidecar.revoke_session(did).await {
4408        Ok(res) => {
4409            info!(%did, revoked = res.revoked, "sidecar session revoked");
4410            true
4411        }
4412        Err(err) => {
4413            warn!(%did, %err, "sidecar revoke failed; continuing");
4414            false
4415        }
4416    };
4417    state.metrics.record(
4418        crate::metrics::Backend::Sidecar,
4419        "oauth_revoke",
4420        sidecar_started.elapsed().as_micros() as u64,
4421        sidecar_ok,
4422    );
4423
4424    if let Some(runtime) = state.oauth.as_deref() {
4425        let revoke_started = std::time::Instant::now();
4426        let outcome = crate::oauth::revoke::sign_out_discovering(
4427            runtime,
4428            &state.http,
4429            &state.db,
4430            did,
4431            crate::store::now_unix(),
4432        )
4433        .await;
4434        // **Counted, because a warn! nobody reads is not observability.** Until
4435        // this existed, a revocation failure left exactly one trace: a log line.
4436        // "No revocation failures this week" was therefore a statement about
4437        // nobody having looked, which is not the same claim.
4438        //
4439        // NoSession counts as a SUCCESS, deliberately. Logout is idempotent —
4440        // there being nothing to revoke is the correct outcome, not a failure,
4441        // and counting it as an error would make the metric noisy in exactly
4442        // the case that is fine. Only `Failed` means the PDS still holds live
4443        // tokens we asked it to drop.
4444        let revoke_ok = !matches!(outcome, crate::oauth::revoke::Revocation::Failed(_));
4445        state.metrics.record(
4446            crate::metrics::Backend::Rust,
4447            "oauth_revoke",
4448            revoke_started.elapsed().as_micros() as u64,
4449            revoke_ok,
4450        );
4451        match outcome {
4452            crate::oauth::revoke::Revocation::Revoked => {
4453                info!(%did, "rust OAuth session revoked at the PDS")
4454            }
4455            crate::oauth::revoke::Revocation::NoSession => {}
4456            crate::oauth::revoke::Revocation::Failed(reason) => {
4457                warn!(%did, %reason, "rust OAuth revoke failed; the local session is gone regardless")
4458            }
4459        }
4460    }
4461}
4462
4463/// `POST /logout` — end the session everywhere, not just in this browser.
4464///
4465/// Clearing the cookie only stops *this* device from presenting the session;
4466/// the sidecar still holds live OAuth tokens for the DID. So logout now also
4467/// calls the sidecar `POST /internal/revoke {did}`, which revokes the refresh +
4468/// access tokens at the PDS and drops the sidecar's session rows. The local
4469/// registry entry is dropped and the cookie cleared regardless of whether the
4470/// revoke round-trip succeeds (best-effort — a network blip must not trap the
4471/// user in a half-logged-out state).
4472async fn logout(State(state): State<AppState>, headers: HeaderMap) -> Response {
4473    if let Some(user) = current_session(&state, &headers).await {
4474        // Only a real cookie session (`sid` present) has sidecar-held tokens to
4475        // revoke; the dev-DID fallback never handshook the sidecar.
4476        if let Some(sid) = user.sid {
4477            state.sessions.remove(&sid);
4478            // BEFORE the revoke: afterwards there is no session to send it with.
4479            flush_before_revoke(&state, &user.did).await;
4480            revoke_everywhere(&state, &user.did).await;
4481        }
4482    }
4483    let mut resp = Redirect::to("/login").into_response();
4484    set_cookie(
4485        &mut resp,
4486        &format!("{SESSION_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
4487    );
4488    resp
4489}
4490
4491/// Form body for `POST /account/delete` — the confirm-gate. The user must type
4492/// `DELETE` into this field for the purge to run.
4493#[derive(Debug, Deserialize)]
4494struct DeleteAccountForm {
4495    #[serde(default)]
4496    confirm: String,
4497}
4498
4499/// The literal a user must type to confirm the destructive delete.
4500const DELETE_CONFIRM_PHRASE: &str = "DELETE";
4501
4502/// `POST /account/delete` (authed) — the "delete my data" endpoint.
4503///
4504/// Confirm-gated: the form must carry `confirm=DELETE` or we bounce back to
4505/// `/manage` with an explanatory flash and touch nothing. On confirmation it:
4506///   1. purges **every** local row owned by the caller DID (`entry_state`,
4507///      `read_cursor`, `sub_ref`, `beta_access` seat, and any invite codes the
4508///      DID created) via [`store::purge_did_data`], then
4509///   2. calls the sidecar `POST /internal/revoke {did}` so the OAuth tokens are
4510///      revoked at the PDS and the sidecar's session rows are dropped, then
4511///   3. drops the in-memory session and clears the cookie, signing the user out.
4512///
4513/// The subscription/folder/saved *records* in the user's own PDS are
4514/// intentionally left alone — they are the user's data on their own server; the
4515/// `/about` copy and this page's UI both say so, and export stays available.
4516async fn account_delete(
4517    State(state): State<AppState>,
4518    headers: HeaderMap,
4519    Form(form): Form<DeleteAccountForm>,
4520) -> Result<Response, WebError> {
4521    let user = match current_session(&state, &headers).await {
4522        Some(u) => u,
4523        None => return Ok(Redirect::to("/login").into_response()),
4524    };
4525    let did = user.did.clone();
4526
4527    // Confirm-gate: require the exact typed phrase before doing anything.
4528    if form.confirm.trim() != DELETE_CONFIRM_PHRASE {
4529        return Ok(Redirect::to(&format!(
4530            "/manage?flash={}",
4531            qenc("Type DELETE to confirm — nothing was deleted.")
4532        ))
4533        .into_response());
4534    }
4535
4536    // 1. Purge every local row this DID owns (single transaction).
4537    let counts = store::purge_did_data(&state.db, &did).await?;
4538    info!(
4539        %did,
4540        total = counts.total(),
4541        entry_state = counts.entry_state,
4542        read_cursor = counts.read_cursor,
4543        sub_ref = counts.sub_ref,
4544        beta_access = counts.beta_access,
4545        invite_codes = counts.invite_codes,
4546        "account/delete: local rows purged"
4547    );
4548
4549    // 2. Revoke the OAuth session at the sidecar/PDS (best-effort — the local
4550    //    rows are already gone; a network blip must not block the sign-out).
4551    revoke_everywhere(&state, &did).await;
4552
4553    // 3. Drop the in-memory session and clear the cookie: sign the user out.
4554    if let Some(sid) = user.sid {
4555        state.sessions.remove(&sid);
4556    }
4557    let mut resp = Redirect::to(&format!(
4558        "/login?flash={}",
4559        qenc("Your data was deleted and you've been signed out. Thanks for trying FeatherReader.")
4560    ))
4561    .into_response();
4562    set_cookie(
4563        &mut resp,
4564        &format!("{SESSION_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
4565    );
4566    Ok(resp)
4567}
4568
4569/// The `/login` card, shared by the form and its error re-render.
4570fn login_card(config: &Config) -> Card {
4571    Card::public(
4572        config,
4573        "/login",
4574        "Sign in — FeatherReader",
4575        "Sign in to FeatherReader with your atproto handle. You approve access on \
4576         your own server — no signup, no password.",
4577    )
4578}
4579
4580/// Re-render the login form with an error banner.
4581fn login_error(state: &AppState, msg: &str) -> Response {
4582    render(&LoginTemplate {
4583        card: login_card(&state.config),
4584        repo_url: REPO_URL,
4585        error: msg.to_string(),
4586        flash: String::new(),
4587    })
4588}
4589
4590// ---------------------------------------------------------------------------
4591// Closed-beta invite gate (self-serve redeem + admin mint)
4592// ---------------------------------------------------------------------------
4593
4594/// Form body for `POST /beta/redeem`.
4595#[derive(Debug, Deserialize)]
4596struct RedeemForm {
4597    code: String,
4598}
4599
4600/// `GET /beta/redeem` — render the invite-redeem page. If the seat cap is
4601/// already full we render the "capacity full" variant (no form).
4602async fn beta_redeem_form(State(state): State<AppState>) -> Response {
4603    let full = store::count_beta_access(&state.db)
4604        .await
4605        .map(|n| n >= state.config.beta_cap)
4606        .unwrap_or(false);
4607    render(&BetaRedeemTemplate {
4608        card: redeem_card(&state.config),
4609        repo_url: REPO_URL,
4610        error: String::new(),
4611        capacity_full: full,
4612    })
4613}
4614
4615/// `POST /beta/redeem` — the **pre-handshake** reservation.
4616///
4617/// Validates the pasted code is *redeemable right now* (exists, active,
4618/// unexpired, and a seat is free) WITHOUT consuming it or binding a DID — the
4619/// visitor has no DID yet. On success it sets a short-lived signed invite cookie
4620/// reserving intent to redeem this code, then sends the visitor to `/login`. The
4621/// OAuth callback later binds the verified DID and atomically consumes the code
4622/// (`store::redeem_code`). This ordering means a non-invited visitor can never
4623/// start OAuth (and burn a sidecar handshake).
4624async fn beta_redeem_submit(
4625    State(state): State<AppState>,
4626    Form(form): Form<RedeemForm>,
4627) -> Response {
4628    let code = form.code.trim().to_uppercase();
4629    if code.is_empty() {
4630        return render(&BetaRedeemTemplate {
4631            card: redeem_card(&state.config),
4632            repo_url: REPO_URL,
4633            error: "Enter your invite code.".to_string(),
4634            capacity_full: false,
4635        });
4636    }
4637
4638    match preflight_code(&state, &code).await {
4639        Ok(()) => {
4640            let cookie = sign_invite(&code, &state.config.cookie_secret);
4641            let mut resp = Redirect::to("/login").into_response();
4642            set_cookie(&mut resp, &cookie);
4643            info!("invite code preflight OK; reserving intent + redirecting to /login");
4644            resp
4645        }
4646        Err(policy) => {
4647            warn!(?policy, "invite code preflight rejected");
4648            redeem_bounce(&state, &policy)
4649        }
4650    }
4651}
4652
4653/// Read-only preflight of an invite code for the pre-handshake reservation:
4654/// verify it exists, is active, is not past `expires_at`, and that a seat is
4655/// free — mirroring the checks `store::redeem_code` will re-run atomically at
4656/// callback time. Does NOT consume the code or grant a seat. Returns the same
4657/// typed [`store::RedeemError`] variants so the two paths share one message map.
4658async fn preflight_code(state: &AppState, code: &str) -> Result<(), store::RedeemError> {
4659    // Cap check first: a clear "capacity full" beats "code invalid" when both.
4660    // FAIL CLOSED on a count error — an `unwrap_or(0)` would let a DB blip read as
4661    // "0 seats used" and wave the redeem through the preflight. (`redeem_code`
4662    // still backstops the real cap inside its tx, so this is a consistency /
4663    // defence-in-depth fix, not the only guard.) Treat an unverifiable count as
4664    // capacity-full: the redeemer sees "at capacity, try later" rather than a mint
4665    // that might overrun the cap.
4666    let count = match store::count_beta_access(&state.db).await {
4667        Ok(n) => n,
4668        Err(err) => {
4669            warn!(%err, "preflight_code: count_beta_access failed; failing closed");
4670            return Err(store::RedeemError::CapacityFull);
4671        }
4672    };
4673    if count >= state.config.beta_cap {
4674        return Err(store::RedeemError::CapacityFull);
4675    }
4676    // Look up the code's current status + expiry (read-only).
4677    let row = sqlx::query_as::<_, (String, i64)>(
4678        "SELECT status, expires_at FROM invite_codes WHERE code = ?1",
4679    )
4680    .bind(code)
4681    .fetch_optional(&state.db)
4682    .await
4683    .ok()
4684    .flatten();
4685    let (status, expires_at) = match row {
4686        Some(r) => r,
4687        None => return Err(store::RedeemError::NotFound),
4688    };
4689    let now = chrono::Utc::now().timestamp();
4690    match status.as_str() {
4691        "active" if expires_at >= now => Ok(()),
4692        "active" => Err(store::RedeemError::Expired),
4693        "expired" => Err(store::RedeemError::Expired),
4694        // "redeemed" or anything else non-active.
4695        _ => Err(store::RedeemError::AlreadyRedeemed),
4696    }
4697}
4698
4699/// Map a [`store::RedeemError`] to the invite page with the right message. Used
4700/// by both the preflight (`POST /beta/redeem`) and the callback bind path.
4701fn redeem_bounce(state: &AppState, policy: &store::RedeemError) -> Response {
4702    use store::RedeemError::*;
4703    let (msg, capacity_full) = match policy {
4704        NotFound => ("That invite code isn't valid.", false),
4705        Expired => ("That invite code has expired.", false),
4706        AlreadyRedeemed => ("That invite code has already been used.", false),
4707        CapacityFull => ("", true),
4708    };
4709    render(&BetaRedeemTemplate {
4710        card: redeem_card(&state.config),
4711        repo_url: REPO_URL,
4712        error: msg.to_string(),
4713        capacity_full,
4714    })
4715}
4716
4717/// The `/beta/redeem` card, shared by the form, its re-renders and the claim
4718/// link's bounce.
4719fn redeem_card(config: &Config) -> Card {
4720    Card::public(
4721        config,
4722        "/beta/redeem",
4723        "Redeem an invite — FeatherReader",
4724        "Redeem a closed-beta invite code for this FeatherReader instance, then sign \
4725         in with your atproto handle.",
4726    )
4727}
4728
4729/// Query for `POST /admin/invites` — how many codes to mint (`?n=`, default 1).
4730#[derive(Debug, Deserialize, Default)]
4731struct MintQuery {
4732    #[serde(default)]
4733    n: Option<u32>,
4734}
4735
4736/// `POST /admin/invites?n=N` — mint N invite codes.
4737///
4738/// `GET /oauth/client-metadata.json` — the client's published identity.
4739///
4740/// **This URL IS the `client_id`.** The PDS fetches it during every login and
4741/// caches it against every existing grant, so it must keep answering at exactly
4742/// this path across the cutover — the sidecar serves the same document at the
4743/// same URL today, proxied by the edge.
4744///
4745/// Served whatever backend is live: a request that arrives here is from a PDS
4746/// resolving our identity, and it has no idea which of our two implementations
4747/// is currently answering repo calls.
4748async fn oauth_client_metadata(State(state): State<AppState>) -> Response {
4749    let Some(runtime) = state.oauth.as_deref() else {
4750        // The sidecar is serving this path in front of us, or nothing is.
4751        return (StatusCode::NOT_FOUND, "no client metadata\n").into_response();
4752    };
4753    axum::Json(crate::oauth::metadata::client_metadata(&runtime.client)).into_response()
4754}
4755
4756/// `GET /oauth/jwks.json` — the client's public signing key.
4757///
4758/// Production only. The localhost dev client is a PUBLIC client: it registers no
4759/// key and signs no assertions, so publishing a JWKS there would advertise a
4760/// credential that is never used — and would make a dev deployment look like a
4761/// confidential client to anyone reading it.
4762async fn oauth_jwks(State(state): State<AppState>) -> Response {
4763    let Some(runtime) = state.oauth.as_deref() else {
4764        return (StatusCode::NOT_FOUND, "no jwks\n").into_response();
4765    };
4766    match runtime.client_key.as_ref() {
4767        Some(key) => match key.jwks_document() {
4768            Ok(doc) => axum::Json(doc).into_response(),
4769            Err(err) => {
4770                warn!(%err, "could not render the client JWKS");
4771                (StatusCode::INTERNAL_SERVER_ERROR, "jwks unavailable\n").into_response()
4772            }
4773        },
4774        None => (StatusCode::NOT_FOUND, "this client publishes no jwks\n").into_response(),
4775    }
4776}
4777
4778/// How many failing feeds `/admin/metrics` will name. One response, so bounded.
4779const ADMIN_FAILING_FEED_LIMIT: i64 = 200;
4780
4781/// `GET /admin/metrics` — repo-op latency for both backends, as plain text.
4782///
4783/// Admin-gated on the same rule as the invite minter: the table names every
4784/// operation the reader performs and how often each fails, which is an
4785/// operational picture rather than public information.
4786///
4787/// Text, not JSON or HTML: it is read by a person deciding whether the cutover
4788/// is safe, and the comparison is two rows side by side.
4789async fn admin_metrics(State(state): State<AppState>, headers: HeaderMap) -> Response {
4790    let did = match current_did(&state, &headers).await {
4791        Some(d) => d,
4792        None => return (StatusCode::UNAUTHORIZED, "sign in first\n").into_response(),
4793    };
4794    if !state.config.admin_seed_dids().iter().any(|d| d == &did) {
4795        warn!(%did, "admin metrics denied: not an admin-seed DID");
4796        return (StatusCode::FORBIDDEN, "not an admin\n").into_response();
4797    }
4798
4799    // Flush first, so the table includes this process's traffic up to now.
4800    // Then read the PERSISTED rows, which is the only place both backends can
4801    // appear at once -- a flip is a restart, and in-process memory only ever
4802    // holds the backend currently running.
4803    if let Err(err) =
4804        crate::metrics::flush(&state.metrics, &state.db, crate::store::now_unix()).await
4805    {
4806        warn!(%err, "could not flush repo timings before rendering");
4807    }
4808    let rows = match crate::metrics::persisted_rows(&state.db).await {
4809        Ok(rows) => rows,
4810        Err(err) => {
4811            warn!(%err, "could not read persisted repo timings");
4812            return (StatusCode::INTERNAL_SERVER_ERROR, "metrics unavailable\n").into_response();
4813        }
4814    };
4815
4816    // The live backend is named at the top: a table of two populated rows is
4817    // ambiguous about which one is currently serving users.
4818    // Parked read-state, alongside the timings. The flusher no longer logs
4819    // these every round (#117), so without a number here the state would be
4820    // silent — which is the failure the noisy loop at least did not have.
4821    let parked = match crate::store::parked_readstate_dids(&state.db).await {
4822        Ok(n) => n.to_string(),
4823        Err(err) => {
4824            warn!(%err, "could not count parked read-state DIDs");
4825            "unknown".to_string()
4826        }
4827    };
4828    // **The half the public histogram cannot carry.** `/stats` reports counts by
4829    // cause and nothing else, deliberately — but `fetch` covers DNS failure,
4830    // timeout, SSRF refusal AND this reader's own bugs, so the count alone
4831    // cannot separate "the publishers are gone" from "we are broken". #159 was
4832    // the latter and took a production investigation to establish. Named feeds
4833    // and their error text belong here, behind ALLOWED_DIDS.
4834    let failing = match crate::store::failing_feeds(&state.db, ADMIN_FAILING_FEED_LIMIT).await {
4835        Ok(f) => f,
4836        Err(err) => {
4837            warn!(%err, "could not list failing feeds");
4838            Vec::new()
4839        }
4840    };
4841    let mut failing_block = String::new();
4842    if !failing.is_empty() {
4843        failing_block.push_str("\nfailing feeds (worst first)\n");
4844        for f in &failing {
4845            failing_block.push_str(&format!(
4846                "  {:>4}x  {:<8}  {}\n          {}\n",
4847                f.consecutive_errors,
4848                f.kind.as_deref().unwrap_or("unknown"),
4849                f.url,
4850                f.detail.as_deref().unwrap_or("(no detail recorded)"),
4851            ));
4852        }
4853    }
4854
4855    // **Capacity that no other page can show.** The global ceiling counts every
4856    // row (`store::count_feeds`), but `/stats` measures the poller and excludes
4857    // unpollable ones — so an instance can be at its cap with every public
4858    // number saying otherwise. A review found exactly that gap.
4859    let unpollable = match crate::store::unpollable_feeds(&state.db).await {
4860        Ok(n) => n,
4861        Err(err) => {
4862            warn!(%err, "could not count unpollable feeds");
4863            -1
4864        }
4865    };
4866    let cached = crate::store::count_feeds(&state.db).await.unwrap_or(-1);
4867
4868    let body = format!(
4869        "live backend: {}\nparked read-state DIDs: {}\n\
4870         feeds cached: {} (ceiling {}), of which unpollable: {}\n\n{}{}",
4871        state.config.repo_backend.as_str(),
4872        parked,
4873        cached,
4874        state.config.max_feeds_global,
4875        unpollable,
4876        crate::metrics::render(&rows),
4877        failing_block,
4878    );
4879    (StatusCode::OK, body).into_response()
4880}
4881
4882/// Authorized ONLY for a live session whose DID is in the `ALLOWED_DIDS` admin
4883/// seed (`config.admin_seed_dids`). Returns the freshly-minted codes as
4884/// newline-separated `text/plain`. Deliberately minimal (no HTML UI).
4885async fn admin_mint_invites(
4886    State(state): State<AppState>,
4887    headers: HeaderMap,
4888    Query(q): Query<MintQuery>,
4889) -> Response {
4890    // Require a real, current session (not just a DID string) whose DID is an
4891    // admin-seed DID. `current_did` already re-checks the beta gate.
4892    let did = match current_did(&state, &headers).await {
4893        Some(d) => d,
4894        None => return (StatusCode::UNAUTHORIZED, "sign in first\n").into_response(),
4895    };
4896    if !state.config.admin_seed_dids().iter().any(|d| d == &did) {
4897        warn!(%did, "admin mint denied: not an admin-seed DID");
4898        return (StatusCode::FORBIDDEN, "not an admin\n").into_response();
4899    }
4900
4901    let n = q.n.unwrap_or(1).clamp(1, 100);
4902    let mut codes = Vec::with_capacity(n as usize);
4903    for _ in 0..n {
4904        match store::mint_code(&state.db, &did, INVITE_TTL_SECS).await {
4905            Ok(code) => codes.push(code),
4906            Err(err) => {
4907                warn!(%err, %did, "admin mint_code failed");
4908                return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
4909            }
4910        }
4911    }
4912    info!(%did, count = codes.len(), "admin minted invite codes");
4913    let mut body = codes.join("\n");
4914    body.push('\n');
4915    (StatusCode::OK, body).into_response()
4916}
4917
4918// ---------------------------------------------------------------------------
4919// Bot claim link: /claim?t=<token>  +  POST /bot/claims (shared-secret mint)
4920// ---------------------------------------------------------------------------
4921
4922/// Query for `GET /claim`.
4923#[derive(Debug, Deserialize)]
4924struct ClaimQuery {
4925    /// The opaque claim token from the bot's public follow-back skeet.
4926    t: Option<String>,
4927}
4928
4929/// `GET /claim?t=<token>` — redeem a bot-issued claim link.
4930///
4931/// The follow→invite bot posts a public skeet mentioning a new follower with a
4932/// link here. The token wraps a pre-minted invite code (never the raw code — see
4933/// [`sign_claim_token`]). On a valid, still-redeemable token this behaves exactly
4934/// like a successful `POST /beta/redeem`: it sets the reserving `fr_invite`
4935/// cookie and sends the visitor to `/login`, so they flow through OAuth and the
4936/// callback atomically consumes the code (`store::redeem_code`) — the same
4937/// machinery as a pasted code. On any failure it bounces to the invite page with
4938/// the matching message.
4939///
4940/// Single-use / grabbability: a token in a public URL is grabbable. The code it
4941/// wraps is single-use (redeem flips `active→redeemed`), and `preflight_code`
4942/// here rejects an already-used / expired / capacity-full code before reserving,
4943/// so a replayed link past the first successful claim is refused. The residual
4944/// window is the same as any pasted invite code: whoever completes OAuth *first*
4945/// with a live reservation wins the seat. The per-IP rate limit on `/claim`
4946/// blunts brute-force enumeration.
4947async fn claim(State(state): State<AppState>, Query(q): Query<ClaimQuery>) -> Response {
4948    let token = match q.t {
4949        Some(t) if !t.is_empty() => t,
4950        _ => {
4951            warn!("claim link with no token");
4952            return redeem_bounce(&state, &store::RedeemError::NotFound);
4953        }
4954    };
4955
4956    // Unwrap the token → the invite code it reserves. A tampered/forged token
4957    // yields nothing → treat as an invalid code (don't leak whether it parsed).
4958    let code = match claim_token_code(&token, &state.config.cookie_secret) {
4959        Some(c) => c,
4960        None => {
4961            warn!("claim token invalid (bad signature / malformed)");
4962            return redeem_bounce(&state, &store::RedeemError::NotFound);
4963        }
4964    };
4965
4966    // Re-run the same preflight as the pasted-code path: exists, active,
4967    // unexpired, seat free. This is what makes a replayed link past first-claim
4968    // (or past cap) fail cleanly.
4969    match preflight_code(&state, &code).await {
4970        Ok(()) => {
4971            let cookie = sign_invite(&code, &state.config.cookie_secret);
4972            let mut resp = Redirect::to("/login").into_response();
4973            set_cookie(&mut resp, &cookie);
4974            info!("claim token preflight OK; reserving intent + redirecting to /login");
4975            resp
4976        }
4977        Err(policy) => {
4978            warn!(?policy, "claim token preflight rejected");
4979            redeem_bounce(&state, &policy)
4980        }
4981    }
4982}
4983
4984/// The JSON body `POST /bot/claims` accepts — the follower the claim is FOR.
4985///
4986/// Passing the follower DID makes the APP the authoritative deduper: the app can
4987/// short-circuit a DID that already holds a seat, and return the SAME code for a
4988/// DID that already has an outstanding claim — so a bot-host state loss cannot
4989/// re-mint or re-post per follower. Handle is advisory (logs only).
4990#[derive(Debug, Default, Deserialize)]
4991struct BotClaimRequest {
4992    /// The follower's DID (the idempotency key). Optional for backward-compat: an
4993    /// omitted DID falls back to the old un-keyed mint (no server-side dedupe).
4994    #[serde(default)]
4995    did: Option<String>,
4996    /// The follower's handle (advisory; recorded for operator logs only).
4997    #[serde(default)]
4998    #[allow(dead_code)]
4999    handle: Option<String>,
5000}
5001
5002/// The JSON body `POST /bot/claims` returns on success.
5003#[derive(Debug, serde::Serialize)]
5004struct BotClaimResponse {
5005    /// Server-side dedupe outcome, so the bot knows whether to post:
5006    /// `"minted"` (a fresh code — post the claim link), `"existing"` (this DID
5007    /// already had an outstanding claim; the SAME code/token/url is returned, so an
5008    /// idempotent re-post is safe), or `"already_seated"` (this DID already holds
5009    /// beta access; code/token/url are empty and the bot should post NOTHING).
5010    status: &'static str,
5011    /// The bare invite code (`FEATHER-…`) — for the bot's own logs/idempotency
5012    /// store. NEVER post this publicly; post the `url` instead. Empty when
5013    /// `already_seated`.
5014    code: String,
5015    /// The opaque claim token (the code wrapped + signed). Empty when
5016    /// `already_seated`.
5017    token: String,
5018    /// The full claim URL to put in the public skeet: `${public_url}/claim?t=…`.
5019    /// Empty when `already_seated`.
5020    url: String,
5021}
5022
5023/// `POST /bot/claims` — headless, shared-secret mint of a claim link.
5024///
5025/// Auth is a bearer shared secret in the `X-Bot-Secret` header (== the Fly secret
5026/// `FEATHERREADER_BOT_SECRET`), NOT an OAuth cookie — so the homelab-hosted bot
5027/// can call it. When `FEATHERREADER_BOT_SECRET` is unset the endpoint is DISABLED
5028/// (503), so a bare/dev instance never exposes an unauthenticated mint.
5029///
5030/// Server-side DID idempotency (the authoritative dedupe backstop): the request
5031/// body carries the follower `did`. The app — not the bot's local SQLite — is the
5032/// source of truth, so a bot-host state loss cannot re-mint or re-post per
5033/// follower:
5034///   * DID already holds beta access → `200 {status:"already_seated"}` (empty
5035///     code/url; the bot marks it handled and posts NOTHING);
5036///   * DID already has an outstanding active claim → `200 {status:"existing"}`
5037///     returning the SAME code/token/url (idempotent — never a second mint);
5038///   * otherwise mint a fresh code recorded FOR that DID → `200 {status:"minted"}`.
5039///
5040/// Cap accounting: the bot must not promise more claims than seats remain, so
5041/// this refuses with `409 Conflict {"error":"full"}` when
5042/// `beta_access + outstanding active codes >= FEATHERREADER_BETA_CAP`. (The
5043/// redeem-time cap in `store::redeem_code` is still the hard backstop.) The count
5044/// queries FAIL CLOSED: a DB error propagates as `500` rather than reading 0 and
5045/// minting past the cap.
5046///
5047/// On a fresh mint it uses the generous claim TTL (`FEATHERREADER_CLAIM_TTL_SECS`,
5048/// default 14d — the admin browser flow's 30-min TTL would expire before the
5049/// follower taps an async-delivered link).
5050async fn bot_mint_claim(
5051    State(state): State<AppState>,
5052    headers: HeaderMap,
5053    body: axum::body::Bytes,
5054) -> Response {
5055    // 1. The endpoint is OFF unless a bot secret is configured.
5056    let bot_secret = match state.config.bot_secret.as_deref() {
5057        Some(s) => s,
5058        None => {
5059            warn!(
5060                "POST /bot/claims called but FEATHERREADER_BOT_SECRET is unset (endpoint disabled)"
5061            );
5062            return (
5063                StatusCode::SERVICE_UNAVAILABLE,
5064                "bot mint endpoint disabled (FEATHERREADER_BOT_SECRET unset)\n",
5065            )
5066                .into_response();
5067        }
5068    };
5069
5070    // 2. Constant-time bearer check on the X-Bot-Secret header.
5071    let presented = headers
5072        .get("x-bot-secret")
5073        .and_then(|v| v.to_str().ok())
5074        .unwrap_or("");
5075    if !bot_secret_matches(presented, bot_secret) {
5076        warn!("POST /bot/claims rejected: bad or missing X-Bot-Secret");
5077        return (StatusCode::UNAUTHORIZED, "bad bot secret\n").into_response();
5078    }
5079
5080    // 2b. Parse the (optional) JSON body → the follower DID/handle. An empty body
5081    // (legacy caller) parses to an all-None request; a malformed body is a 400.
5082    let req: BotClaimRequest = if body.is_empty() {
5083        BotClaimRequest::default()
5084    } else {
5085        match serde_json::from_slice(&body) {
5086            Ok(r) => r,
5087            Err(err) => {
5088                warn!(%err, "POST /bot/claims: bad JSON body");
5089                return (StatusCode::BAD_REQUEST, "bad json body\n").into_response();
5090            }
5091        }
5092    };
5093    let follower_did = req.did.as_deref().filter(|d| !d.is_empty());
5094
5095    // 3. Server-side DID idempotency (only when a DID was supplied):
5096    if let Some(did) = follower_did {
5097        // 3a. Already seated → tell the bot to post nothing.
5098        match store::has_beta_access(&state.db, did).await {
5099            Ok(true) => {
5100                info!("bot mint: DID already holds beta access; already_seated");
5101                return bot_claim_json(BotClaimResponse {
5102                    status: "already_seated",
5103                    code: String::new(),
5104                    token: String::new(),
5105                    url: String::new(),
5106                });
5107            }
5108            Ok(false) => {}
5109            Err(err) => {
5110                // Fail closed: a DB error must not fall through to a fresh mint.
5111                warn!(%err, "bot mint: has_beta_access failed");
5112                return (StatusCode::INTERNAL_SERVER_ERROR, "lookup failed\n").into_response();
5113            }
5114        }
5115        // 3b. Outstanding active claim for this DID → return the SAME code (no
5116        // second mint). This is what survives a bot-host state loss.
5117        match store::find_active_code_for_did(&state.db, did).await {
5118            Ok(Some(code)) => {
5119                info!("bot mint: existing outstanding claim for DID; returning same code");
5120                let token = sign_claim_token(&code, &state.config.cookie_secret);
5121                let url = format!("{}/claim?t={}", state.config.public_url, qenc(&token));
5122                return bot_claim_json(BotClaimResponse {
5123                    status: "existing",
5124                    code,
5125                    token,
5126                    url,
5127                });
5128            }
5129            Ok(None) => {}
5130            Err(err) => {
5131                warn!(%err, "bot mint: find_active_code_for_did failed");
5132                return (StatusCode::INTERNAL_SERVER_ERROR, "lookup failed\n").into_response();
5133            }
5134        }
5135    }
5136
5137    // 4. Cap accounting: seats already granted + outstanding unredeemed codes.
5138    //    FAIL CLOSED — a count error is a 500, not a silent mint past the cap.
5139    let granted = match store::count_beta_access(&state.db).await {
5140        Ok(n) => n,
5141        Err(err) => {
5142            warn!(%err, "bot mint: count_beta_access failed; failing closed");
5143            return (StatusCode::INTERNAL_SERVER_ERROR, "count failed\n").into_response();
5144        }
5145    };
5146    let outstanding = match store::count_active_codes(&state.db).await {
5147        Ok(n) => n,
5148        Err(err) => {
5149            warn!(%err, "bot mint: count_active_codes failed; failing closed");
5150            return (StatusCode::INTERNAL_SERVER_ERROR, "count failed\n").into_response();
5151        }
5152    };
5153    if granted + outstanding >= state.config.beta_cap {
5154        info!(
5155            granted,
5156            outstanding,
5157            cap = state.config.beta_cap,
5158            "bot mint refused: at capacity"
5159        );
5160        return (
5161            StatusCode::CONFLICT,
5162            [(header::CONTENT_TYPE, "application/json")],
5163            "{\"error\":\"full\"}\n",
5164        )
5165            .into_response();
5166    }
5167
5168    // 5. Mint with the generous claim TTL, recording the follower DID (when given)
5169    //    so a re-request for the same DID returns THIS code idempotently.
5170    let bot_did = state
5171        .config
5172        .admin_seed_dids()
5173        .first()
5174        .cloned()
5175        .unwrap_or_else(|| "did:bot:featherreader".to_string());
5176    let minted = match follower_did {
5177        Some(did) => {
5178            store::mint_code_for_did(&state.db, &bot_did, state.config.claim_ttl_secs, did).await
5179        }
5180        None => store::mint_code(&state.db, &bot_did, state.config.claim_ttl_secs).await,
5181    };
5182    let code = match minted {
5183        Ok(c) => c,
5184        // S4: the dedupe check (3b) and this mint are separate statements, so two
5185        // concurrent requests for one DID can both fall through 3b's `Ok(None)`.
5186        // The partial unique index `idx_invite_codes_intended_active` makes the
5187        // loser's INSERT fail (only one active row per intended DID), which
5188        // surfaces here as a conflict. Recover by returning the winner's existing
5189        // code (same shape as the 3b idempotent path) instead of a 500.
5190        Err(err) if follower_did.is_some() && store::is_intended_active_conflict(&err) => {
5191            match store::find_active_code_for_did(&state.db, follower_did.unwrap()).await {
5192                Ok(Some(code)) => {
5193                    info!("bot mint: lost the mint race; returning the concurrently-minted code");
5194                    let token = sign_claim_token(&code, &state.config.cookie_secret);
5195                    let url = format!("{}/claim?t={}", state.config.public_url, qenc(&token));
5196                    return bot_claim_json(BotClaimResponse {
5197                        status: "existing",
5198                        code,
5199                        token,
5200                        url,
5201                    });
5202                }
5203                // The winner's row vanished between the conflict and this lookup
5204                // (redeemed/expired/purged in the gap) — nothing to hand back.
5205                // Fail closed rather than silently mint past the just-hit guard.
5206                Ok(None) => {
5207                    warn!("bot mint: conflict but no active code found on recovery");
5208                    return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
5209                }
5210                Err(err) => {
5211                    warn!(%err, "bot mint: recovery lookup after conflict failed");
5212                    return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
5213                }
5214            }
5215        }
5216        Err(err) => {
5217            warn!(%err, "bot mint_code failed");
5218            return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
5219        }
5220    };
5221    let token = sign_claim_token(&code, &state.config.cookie_secret);
5222    let url = format!("{}/claim?t={}", state.config.public_url, qenc(&token));
5223    info!("bot minted a claim code + token");
5224
5225    bot_claim_json(BotClaimResponse {
5226        status: "minted",
5227        code,
5228        token,
5229        url,
5230    })
5231}
5232
5233/// Serialize a [`BotClaimResponse`] to a `200 application/json` response (or a
5234/// `500` if serialization somehow fails).
5235fn bot_claim_json(resp: BotClaimResponse) -> Response {
5236    match serde_json::to_string(&resp) {
5237        Ok(body) => (
5238            StatusCode::OK,
5239            [(header::CONTENT_TYPE, "application/json")],
5240            body,
5241        )
5242            .into_response(),
5243        Err(err) => {
5244            warn!(%err, "serializing bot claim response failed");
5245            (StatusCode::INTERNAL_SERVER_ERROR, "serialize failed\n").into_response()
5246        }
5247    }
5248}
5249
5250/// Constant-time equality for the bot bearer secret (avoid a timing side-channel
5251/// on the shared secret). Delegates to the same `cookie::constant_time_eq` used
5252/// by the HMAC checks so there is one comparator to audit; a length mismatch
5253/// short-circuits to `false`, which is fine — the secret length isn't sensitive.
5254fn bot_secret_matches(presented: &str, expected: &str) -> bool {
5255    cookie::constant_time_eq(presented.as_bytes(), expected.as_bytes())
5256}
5257
5258// ---------------------------------------------------------------------------
5259// Signed, short-lived invite cookie (reuses the session-cookie HMAC helper)
5260// ---------------------------------------------------------------------------
5261
5262/// Sign the reserved invite `code` into a short-lived `Set-Cookie` value. Reuses
5263/// the same HMAC-SHA256 helper as the session cookie; the payload is the code
5264/// itself (base64url) rather than an opaque sid, since the code IS the reserved
5265/// intent the callback consumes.
5266fn sign_invite(code: &str, secret: &str) -> String {
5267    cookie::sign_value(INVITE_COOKIE, code, secret, INVITE_TTL_SECS)
5268}
5269
5270/// Verify + read the reserved invite code out of the request's invite cookie
5271/// (`None` if absent, tampered, or forged). No expiry is enforced here beyond
5272/// the cookie's own `Max-Age`; the atomic `redeem_code` at the callback is the
5273/// authority on the code's live status.
5274fn invite_cookie_code(headers: &HeaderMap, secret: &str) -> Option<String> {
5275    cookie::verify_value(headers, INVITE_COOKIE, secret)
5276}
5277
5278/// Domain-separation label for the claim TOKEN's HMAC (distinct from the
5279/// `fr_invite`/`fr_session` cookie names), so a token can never be replayed as a
5280/// cookie value and vice-versa.
5281const CLAIM_TOKEN_LABEL: &str = "claim-token";
5282
5283/// Sign an invite `code` into a URL-safe claim TOKEN: `b64url(code).<sig>`
5284/// (HMAC-SHA256 over `"claim-token" || 0x00 || code`).
5285///
5286/// NOTE — the token is NOT confidential: the `b64url(code)` half is trivially
5287/// decodable by anyone, so the raw `FEATHER-…` code is effectively public in the
5288/// claim URL. The token's security is INTEGRITY + SINGLE-USE, not secrecy: the
5289/// `<sig>` HMAC means only this instance can MINT a valid token (a forged/guessed
5290/// code won't verify), the wrapped code is single-use (redeem flips
5291/// `active→redeemed`), and `/claim` is per-IP rate-limited. Wrapping keeps the
5292/// token one self-contained string needing no server-side token table; it does
5293/// NOT hide the code.
5294fn sign_claim_token(code: &str, secret: &str) -> String {
5295    cookie::sign_token(CLAIM_TOKEN_LABEL, code, secret)
5296}
5297
5298/// Verify a claim token and return the invite code it wraps (`None` on a tampered
5299/// / forged / malformed token). The code's live status (active/unexpired/seat
5300/// free) is re-checked by `preflight_code`; this only proves the token was minted
5301/// by this instance.
5302fn claim_token_code(token: &str, secret: &str) -> Option<String> {
5303    cookie::verify_token(CLAIM_TOKEN_LABEL, token, secret)
5304}
5305
5306/// Clear the invite cookie on a response (after a successful bind, or when the
5307/// reservation turned out to be stale).
5308fn clear_invite_cookie(resp: &mut Response) {
5309    set_cookie(
5310        resp,
5311        &format!("{INVITE_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
5312    );
5313}
5314
5315// ---------------------------------------------------------------------------
5316// OPML import + export
5317// ---------------------------------------------------------------------------
5318
5319/// `POST /opml` — import subscriptions from an OPML document.
5320///
5321/// Accepts either a multipart file upload (field `file`) or a pasted textarea
5322/// (field `opml`). The parsed feeds each become a `community.lexicon.rss.folder`
5323/// (for any named folders) + a `community.lexicon.rss.subscription` record in the
5324/// user's PDS via the records layer's bulk-add (`add_subscriptions_bulk`, one
5325/// `applyWrites` round-trip per 200 feeds). Feeds are also upserted into the local cache so
5326/// they show immediately; polling is left to the background poller.
5327async fn import_opml(
5328    State(state): State<AppState>,
5329    headers: HeaderMap,
5330    mut multipart: Multipart,
5331) -> Result<Response, WebError> {
5332    let did = match current_did(&state, &headers).await {
5333        Some(d) => d,
5334        None => return Ok(Redirect::to("/login").into_response()),
5335    };
5336    let pool = &state.db;
5337
5338    // Collect the OPML text from whichever field carried it. Multipart errors
5339    // are mapped to their axum-native response so that an over-cap upload (the
5340    // `DefaultBodyLimit` on this route, see `OPML_BODY_LIMIT`) surfaces as
5341    // `413 Payload Too Large` rather than being swallowed by the blanket
5342    // `WebError` → `500` conversion.
5343    let mut opml_text = String::new();
5344    while let Some(field) = multipart.next_field().await.map_err(multipart_response)? {
5345        let name = field.name().unwrap_or("").to_string();
5346        if name == "opml" || name == "file" {
5347            let bytes = field.bytes().await.map_err(multipart_response)?;
5348            if !bytes.is_empty() {
5349                opml_text = String::from_utf8_lossy(&bytes).into_owned();
5350                if name == "file" {
5351                    break;
5352                }
5353            }
5354        }
5355    }
5356
5357    // A parse FAILURE and an empty-but-valid file are different things, and
5358    // `unwrap_or_default` collapsed them: a malformed export was reported to the
5359    // reader as "No feeds found in that OPML", which sends them looking at their
5360    // old reader for feeds that are right there in the file.
5361    let feeds =
5362        match opml::parse_opml(&opml_text) {
5363            Ok(feeds) => feeds,
5364            Err(err) => {
5365                warn!(%err, %did, "OPML import could not parse the uploaded file");
5366                return Ok(Redirect::to(&format!(
5367                "/?flash={}",
5368                qenc("That file could not be read as OPML. Export it again from your other reader?")
5369            ))
5370                .into_response());
5371            }
5372        };
5373    if feeds.is_empty() {
5374        info!(%did, "OPML import found no feeds");
5375        return Ok(
5376            Redirect::to(&format!("/?flash={}", qenc("No feeds found in that OPML")))
5377                .into_response(),
5378        );
5379    }
5380
5381    // Create any named folders first, mapping folder name → at:// URI so
5382    // subscriptions can reference them.
5383    let now = now_rfc3339();
5384    let mut folder_uris: std::collections::HashMap<String, String> =
5385        std::collections::HashMap::new();
5386    // Reuse existing folders where the name already exists.
5387    if let Ok(existing) = state.repo().list_folders_sorted(&did).await {
5388        for (rkey, folder) in existing {
5389            folder_uris
5390                .entry(folder.name.clone())
5391                .or_insert_with(|| folder_uri(&did, &rkey));
5392        }
5393    }
5394    let mut wanted_folders: Vec<String> = feeds
5395        .iter()
5396        .filter_map(|f| f.folder.clone())
5397        .filter(|n| !n.is_empty())
5398        .collect();
5399    wanted_folders.sort();
5400    wanted_folders.dedup();
5401    for name in wanted_folders {
5402        if folder_uris.contains_key(&name) {
5403            continue;
5404        }
5405        let folder = Folder::new(name.clone(), now.clone());
5406        match state.repo().add_folder(&did, &folder).await {
5407            Ok(rkey) => {
5408                folder_uris.insert(name, folder_uri(&did, &rkey));
5409            }
5410            Err(err) => warn!(%err, %did, "OPML folder create failed"),
5411        }
5412    }
5413
5414    // Build one subscription record per PUBLIC feed + upsert the local cache row.
5415    // Private/paid feeds are SKIPPED entirely (never stored, fetched, or written)
5416    // and reported back to the user — the same public-feeds-only stance as the
5417    // single-add path, so an OPML import can't leak a Substack/Patreon/podcast
5418    // token onto the public network either.
5419    // Per-DID subscription cap: an OPML import must not blow past the cap. Compute
5420    // the remaining headroom (cap − existing) once; public feeds beyond it are
5421    // TRIMMED (not imported) and reported. `<= 0` disables the cap.
5422    let sub_cap = state.config.max_subs_per_did;
5423    let mut headroom: Option<i64> = if sub_cap > 0 {
5424        let existing = store::count_subscriptions_for_did(pool, &did)
5425            .await
5426            .unwrap_or(0);
5427        Some((sub_cap - existing).max(0))
5428    } else {
5429        None
5430    };
5431    let mut trimmed_over_cap: usize = 0;
5432
5433    // Global feeds ceiling: an OPML import must not blow past the shared cache
5434    // ceiling any more than the single-add path may. Seed the remaining global
5435    // headroom (cap − current feeds) once, and only a BRAND-NEW feed URL (one
5436    // not already cached) consumes it. Existing/duplicate URLs add no row and
5437    // are always allowed. Feeds past the ceiling are TRIMMED and reported.
5438    // `<= 0` disables the ceiling.
5439    let feeds_cap = state.config.max_feeds_global;
5440    let mut global_headroom: Option<i64> = if feeds_cap > 0 {
5441        let existing = store::count_feeds(pool).await.unwrap_or(0);
5442        Some((feeds_cap - existing).max(0))
5443    } else {
5444        None
5445    };
5446    let mut trimmed_over_global: usize = 0;
5447
5448    let mut subs = Vec::with_capacity(feeds.len());
5449    let mut skipped_private: Vec<String> = Vec::new();
5450    // Imported into the PDS but not cached locally, so not pollable until the
5451    // next import touches them. Counted rather than only logged — see below.
5452    let mut uncached: usize = 0;
5453    // Entries this instance cannot store at all (an `at://` publication with
5454    // the flag off, an unsupported scheme). Counted, because the `continue`
5455    // below used to increment nothing while the privacy branch beside it
5456    // produced a label — so an OPML from a standard.site-enabled instance
5457    // imported "successfully" with entries missing and no reason given.
5458    let mut skipped_unsupported: usize = 0;
5459    for f in &feeds {
5460        // `xmlUrl` is whatever the uploaded file says, and nothing on this path
5461        // ever parsed it — the single-add path can't reach here because
5462        // `resolve_feed_url` must parse AND successfully fetch first. So
5463        // `javascript:alert(1)` and `file:///etc/passwd` were both accepted,
5464        // cached, and published as records to the user's PUBLIC repo. Note that
5465        // `classify_feed_privacy` does not catch these: both parse cleanly, and
5466        // it returns `Public` for anything unparseable by design.
5467        if !feed::is_storable_feed_url(&f.feed_url, state.config.standard_site) {
5468            info!(
5469                %did,
5470                "skipped an OPML entry whose xmlUrl is not a storable feed URL"
5471            );
5472            skipped_unsupported += 1;
5473            continue;
5474        }
5475        if let feed::FeedPrivacy::Private(reason) = feed::classify_feed_privacy(&f.feed_url) {
5476            info!(feed = %f.feed_url, %reason, %did, "skipped private/paid feed on OPML import (not stored)");
5477            // Report by title where we have one, else the (public-safe) host.
5478            let label = f
5479                .title
5480                .clone()
5481                .filter(|t| !t.trim().is_empty())
5482                .unwrap_or_else(|| private_feed_label(&f.feed_url));
5483            skipped_private.push(label);
5484            continue;
5485        }
5486
5487        // Over-cap: stop importing once headroom is exhausted (count the rest so
5488        // we can tell the user how many were dropped).
5489        if let Some(h) = headroom.as_mut() {
5490            if *h <= 0 {
5491                trimmed_over_cap += 1;
5492                continue;
5493            }
5494        }
5495
5496        // Global ceiling: a brand-new feed URL consumes global headroom. Once
5497        // it's exhausted, refuse to cache further NEW feeds (existing URLs are
5498        // free — they add no row). Checked before decrementing the per-DID
5499        // headroom so a dropped feed doesn't burn the caller's own quota.
5500        let is_new = match store::get_feed_by_url(pool, &f.feed_url).await {
5501            Ok(existing) => existing.is_none(),
5502            // On a lookup error, treat as existing (don't consume global
5503            // headroom) but still allow the upsert to proceed.
5504            Err(err) => {
5505                warn!(%err, feed = %f.feed_url, "get_feed_by_url failed during OPML global-cap check");
5506                false
5507            }
5508        };
5509        if is_new {
5510            if let Some(g) = global_headroom.as_mut() {
5511                if *g <= 0 {
5512                    trimmed_over_global += 1;
5513                    continue;
5514                }
5515                *g -= 1;
5516            }
5517        }
5518
5519        // Passed both caps: consume the per-DID headroom now that the feed is
5520        // actually being imported.
5521        if let Some(h) = headroom.as_mut() {
5522            *h -= 1;
5523        }
5524
5525        let mut sub = Subscription::new(f.feed_url.clone(), now.clone());
5526        sub.title = f.title.clone();
5527        sub.site_url = f.site_url.clone();
5528        sub.folder = f
5529            .folder
5530            .as_ref()
5531            .and_then(|name| folder_uris.get(name).cloned());
5532        subs.push(sub);
5533        // Same support ticket as the single-add path: no `feeds` row means the
5534        // poller never selects this subscription, so the import looks like it
5535        // worked and the feed silently never updates. Counted as well as logged,
5536        // because one line per feed in a 200-feed import is not something anyone
5537        // reads — the count goes to the reader.
5538        if let Err(err) = store::upsert_feed(
5539            pool,
5540            &store::NewFeed {
5541                url: f.feed_url.clone(),
5542                title: f.title.clone(),
5543                site_url: f.site_url.clone(),
5544                ..Default::default()
5545            },
5546        )
5547        .await
5548        {
5549            warn!(%err, %did, url = %f.feed_url, "OPML import could not cache a feed; \
5550                                                  it will not be polled");
5551            uncached += 1;
5552        }
5553    }
5554
5555    // **A failed PDS write is not an import.**
5556    //
5557    // The subscriptions live in the reader's repo; a local `feeds` row is just a
5558    // poller hint. This used to `warn!` and then report "Imported N feeds"
5559    // regardless, so a total failure read as a total success — and the reader
5560    // would only discover otherwise on their next visit, with an empty sidebar.
5561    //
5562    // **And a part-landed write is not a failed one.** The batch goes out in
5563    // calls of at most 200 (#240: the reference PDS refuses more), sent in
5564    // order and stopped at the first failure, so what landed is a prefix of
5565    // `subs` and the error says how long. Saying "nothing was imported" after
5566    // the first 200 of 450 landed would send the reader to import the file
5567    // again, which adds those 200 a second time. Nothing local needs undoing
5568    // either way: the reader's `sub_ref` projection is rebuilt from the repo on
5569    // the next read, and a cached `feeds` row with no subscriber is the same
5570    // poller hint the total-failure path has always left behind.
5571    let landed = match state.repo().add_subscriptions_bulk(&did, &subs).await {
5572        Ok(rkeys) => {
5573            info!(%did, count = rkeys.len(), skipped = skipped_private.len(), "imported OPML subscriptions to PDS (batched)");
5574            rkeys.len()
5575        }
5576        Err(err) => {
5577            let landed = crate::atproto::ApplyWritesIncomplete::of(&err).map_or(0, |p| p.landed);
5578            warn!(%err, %did, landed, total = subs.len(), "OPML PDS batch write failed (feeds cached locally)");
5579            landed
5580        }
5581    };
5582    if landed == 0 && !subs.is_empty() {
5583        return Ok(Redirect::to(&format!(
5584            "/?flash={}",
5585            qenc(
5586                "Could not save those subscriptions to your PDS, so nothing was imported. \
5587                 Try again in a moment."
5588            )
5589        ))
5590        .into_response());
5591    }
5592
5593    // Report the import count, plus any private/paid feeds skipped as unsupported.
5594    let mut flash = if landed < subs.len() {
5595        format!(
5596            "Imported {landed} of {} feeds: your PDS stopped accepting them part-way, so the \
5597             other {} may not have been saved. Importing the same file again would add the first \
5598             {landed} a second time",
5599            subs.len(),
5600            subs.len() - landed
5601        )
5602    } else {
5603        format!("Imported {} feeds", subs.len())
5604    };
5605    if uncached > 0 {
5606        flash.push_str(&format!(
5607            ". {uncached} of them could not be cached locally and may not update until the next import."
5608        ));
5609    }
5610    if trimmed_over_cap > 0 {
5611        flash.push_str(&format!(
5612            ". {trimmed_over_cap} feed(s) not imported: your subscription limit ({sub_cap}) was reached."
5613        ));
5614    }
5615    if trimmed_over_global > 0 {
5616        flash.push_str(&format!(
5617            ". {trimmed_over_global} feed(s) not imported: this instance is at its feed capacity right now."
5618        ));
5619    }
5620    if !skipped_private.is_empty() {
5621        flash.push_str(&format!(
5622            ". {} feed(s) skipped as private/paid: {} — not supported yet (public feeds only for now).",
5623            skipped_private.len(),
5624            skipped_private.join(", ")
5625        ));
5626    }
5627    if skipped_unsupported > 0 {
5628        // By count only — the URL is whatever the file said, and unlike the
5629        // private branch there is no public-safe label to give.
5630        flash.push_str(&format!(
5631            ". {skipped_unsupported} feed(s) skipped: not a kind of feed this instance can subscribe to."
5632        ));
5633    }
5634    Ok(Redirect::to(&format!("/?flash={}", qenc(&flash))).into_response())
5635}
5636
5637/// A public-safe label for a skipped private feed when it has no title: just the
5638/// host, so we never echo the secret-bearing path/query back to the user.
5639fn private_feed_label(url: &str) -> String {
5640    url::Url::parse(url)
5641        .ok()
5642        .and_then(|u| u.host_str().map(str::to_string))
5643        .unwrap_or_else(|| "a private feed".to_string())
5644}
5645
5646/// `GET /opml/export` — export the user's subscriptions + folders as OPML.
5647async fn export_opml(
5648    State(state): State<AppState>,
5649    headers: HeaderMap,
5650) -> Result<Response, WebError> {
5651    let did = match current_did(&state, &headers).await {
5652        Some(d) => d,
5653        None => return Ok(Redirect::to("/login").into_response()),
5654    };
5655
5656    // **An export must never be silently empty.** `unwrap_or_default` here turned
5657    // a failed read into a 200 carrying a zero-feed OPML file — the reader's
5658    // backup, blank, at exactly the moment they reached for it. That was survivable
5659    // while a truncated walk returned `Ok`; now that the walk refuses a short list,
5660    // this is the one caller that converts a refusal into data loss, and it is also
5661    // the recovery route the changelog points a locked-out reader at.
5662    let subs = match state.repo().list_subscriptions_sorted(&did).await {
5663        Ok(subs) => subs,
5664        Err(err) => {
5665            tracing::warn!(%err, did = %did, "refusing to export an OPML we could not read in full");
5666            return Ok(Redirect::to(&format!(
5667                "/manage?flash={}",
5668                qenc(EXPORT_INCOMPLETE_REFUSAL)
5669            ))
5670            .into_response());
5671        }
5672    };
5673    let folders = match state.repo().list_folders_sorted(&did).await {
5674        Ok(folders) => folders,
5675        Err(err) => {
5676            tracing::warn!(%err, did = %did, "refusing to export an OPML without its folders");
5677            return Ok(Redirect::to(&format!(
5678                "/manage?flash={}",
5679                qenc(EXPORT_INCOMPLETE_REFUSAL)
5680            ))
5681            .into_response());
5682        }
5683    };
5684    // The exporter matches a subscription's `folder` at-uri against the folder's
5685    // pair key; our folder pairs are keyed by rkey, so rebuild them as at-uris.
5686    let folder_pairs: Vec<(String, Folder)> = folders
5687        .into_iter()
5688        .map(|(rkey, f)| (folder_uri(&did, &rkey), f))
5689        .collect();
5690
5691    let body = opml::to_opml(&subs, &folder_pairs);
5692    let mut resp = (StatusCode::OK, body).into_response();
5693    resp.headers_mut().insert(
5694        header::CONTENT_TYPE,
5695        "text/x-opml; charset=utf-8".parse().unwrap(),
5696    );
5697    resp.headers_mut().insert(
5698        header::CONTENT_DISPOSITION,
5699        "attachment; filename=\"featherreader-subscriptions.opml\""
5700            .parse()
5701            .unwrap(),
5702    );
5703    Ok(resp)
5704}
5705
5706// ---------------------------------------------------------------------------
5707// Signed session cookie (HMAC-SHA256, dependency-free)
5708// ---------------------------------------------------------------------------
5709
5710/// Set a `Set-Cookie` header on a response (append, so logout+redirect compose).
5711fn set_cookie(resp: &mut Response, cookie: &str) {
5712    if let Ok(value) = axum::http::HeaderValue::from_str(cookie) {
5713        resp.headers_mut()
5714            .append(axum::http::header::SET_COOKIE, value);
5715    }
5716}
5717
5718/// Whether the request came from htmx (the `HX-Request` header).
5719fn is_htmx(headers: &HeaderMap) -> bool {
5720    headers
5721        .get("HX-Request")
5722        .is_some_and(|v| v.as_bytes().eq_ignore_ascii_case(b"true"))
5723}
5724
5725/// Whether a mark-read / star request originated from the single-entry READER
5726/// (as opposed to the list view). The reader's forms tag themselves with
5727/// `X-FR-Reader: 1` via `hx-headers`; the list view's do not. This selects the
5728/// swap fragment: the reader gets an out-of-band action-bar update (its `<li>`
5729/// isn't in the DOM), the list gets the row (`entry_row.html`).
5730fn is_reader_request(headers: &HeaderMap) -> bool {
5731    headers
5732        .get("X-FR-Reader")
5733        .is_some_and(|v| v.as_bytes() == b"1")
5734}
5735
5736/// A tiny, self-contained signed-cookie layer: HMAC-SHA256 over an opaque,
5737/// server-minted **session id** (never the DID — so the cookie can't be forged
5738/// from a resolved victim DID; forging it needs the HMAC secret *and* a live
5739/// server-side session id).
5740mod cookie {
5741    use super::{HeaderMap, SESSION_COOKIE};
5742
5743    /// Sign a session id into a `Set-Cookie` header value: `fr_session=<sid>.<sig>`.
5744    pub fn sign_session(sid: &str, secret: &str) -> String {
5745        sign_value(SESSION_COOKIE, sid, secret, 2_592_000)
5746    }
5747
5748    /// Verify the request's session cookie and return the session id it carries.
5749    pub fn verify_session(headers: &HeaderMap, secret: &str) -> Option<String> {
5750        verify_value(headers, SESSION_COOKIE, secret)
5751    }
5752
5753    /// The HMAC message binding the cookie NAME to its value (`name || 0x00 ||
5754    /// value`), so a signature minted for one cookie can't verify under another —
5755    /// e.g. a value validly signed as `fr_invite` is not accepted as `fr_session`.
5756    /// The NUL separator can't appear in a cookie name, so the encoding is
5757    /// unambiguous.
5758    fn cookie_hmac_msg(name: &str, value: &str) -> Vec<u8> {
5759        let mut msg = Vec::with_capacity(name.len() + 1 + value.len());
5760        msg.extend_from_slice(name.as_bytes());
5761        msg.push(0);
5762        msg.extend_from_slice(value.as_bytes());
5763        msg
5764    }
5765
5766    /// Sign an arbitrary string `value` into a `Set-Cookie` header for `name`,
5767    /// HMAC-SHA256 over `name || 0x00 || value`: `name=<b64url(value)>.<sig>`. The
5768    /// generic form behind both the session cookie and the short-lived invite
5769    /// cookie; domain-separating by name keeps a signature valid only for the
5770    /// cookie it was minted for.
5771    pub fn sign_value(name: &str, value: &str, secret: &str, max_age_secs: i64) -> String {
5772        let sig = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(name, value));
5773        let b64 = b64url_encode(value.as_bytes());
5774        format!(
5775            "{name}={b64}.{sig}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age={max_age_secs}"
5776        )
5777    }
5778
5779    /// Verify + read a value out of the named signed cookie (`None` on absent /
5780    /// tampered / forged / cross-cookie). The generic form behind both readers.
5781    pub fn verify_value(headers: &HeaderMap, name: &str, secret: &str) -> Option<String> {
5782        let raw = cookie_value(headers, name)?;
5783        let (b64, sig) = raw.split_once('.')?;
5784        let bytes = b64url_decode(b64)?;
5785        let value = String::from_utf8(bytes).ok()?;
5786        let expected = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(name, &value));
5787        if constant_time_eq(expected.as_bytes(), sig.as_bytes()) {
5788            Some(value)
5789        } else {
5790            None
5791        }
5792    }
5793
5794    /// Sign an arbitrary `value` into an opaque, URL-safe token string
5795    /// `b64url(value).<sig>` (HMAC-SHA256 over `label || 0x00 || value`). Unlike
5796    /// [`sign_value`] this is NOT a `Set-Cookie` header — it's a bare token for a
5797    /// URL query param (the bot's claim link). `label` domain-separates it from
5798    /// the cookies so a token can't be replayed as a cookie value.
5799    pub fn sign_token(label: &str, value: &str, secret: &str) -> String {
5800        let sig = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(label, value));
5801        let b64 = b64url_encode(value.as_bytes());
5802        format!("{b64}.{sig}")
5803    }
5804
5805    /// Verify a token minted by [`sign_token`] and return the wrapped value
5806    /// (`None` on tamper / forge / malformed). Constant-time signature compare.
5807    pub fn verify_token(label: &str, token: &str, secret: &str) -> Option<String> {
5808        let (b64, sig) = token.split_once('.')?;
5809        let bytes = b64url_decode(b64)?;
5810        let value = String::from_utf8(bytes).ok()?;
5811        let expected = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(label, &value));
5812        if constant_time_eq(expected.as_bytes(), sig.as_bytes()) {
5813            Some(value)
5814        } else {
5815            None
5816        }
5817    }
5818
5819    /// Pull one cookie value out of the `Cookie` request header.
5820    fn cookie_value(headers: &HeaderMap, name: &str) -> Option<String> {
5821        let header = headers.get(axum::http::header::COOKIE)?.to_str().ok()?;
5822        for part in header.split(';') {
5823            let part = part.trim();
5824            if let Some((k, v)) = part.split_once('=') {
5825                if k == name {
5826                    return Some(v.to_string());
5827                }
5828            }
5829        }
5830        None
5831    }
5832
5833    /// Constant-time byte comparison (avoid signature-timing leaks). Public
5834    /// within the module so the bot-secret bearer check reuses the exact same
5835    /// comparator as the cookie/token HMAC checks (one implementation to audit).
5836    pub fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
5837        if a.len() != b.len() {
5838            return false;
5839        }
5840        let mut diff = 0u8;
5841        for (x, y) in a.iter().zip(b.iter()) {
5842            diff |= x ^ y;
5843        }
5844        diff == 0
5845    }
5846
5847    // -- URL-safe base64 (no padding), std-only --------------------------------
5848
5849    const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
5850
5851    fn b64url_encode(input: &[u8]) -> String {
5852        let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
5853        for chunk in input.chunks(3) {
5854            let b = [
5855                chunk[0],
5856                *chunk.get(1).unwrap_or(&0),
5857                *chunk.get(2).unwrap_or(&0),
5858            ];
5859            let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | (b[2] as u32);
5860            out.push(B64[((n >> 18) & 63) as usize] as char);
5861            out.push(B64[((n >> 12) & 63) as usize] as char);
5862            if chunk.len() > 1 {
5863                out.push(B64[((n >> 6) & 63) as usize] as char);
5864            }
5865            if chunk.len() > 2 {
5866                out.push(B64[(n & 63) as usize] as char);
5867            }
5868        }
5869        out
5870    }
5871
5872    fn b64url_decode(input: &str) -> Option<Vec<u8>> {
5873        fn val(c: u8) -> Option<u32> {
5874            match c {
5875                b'A'..=b'Z' => Some((c - b'A') as u32),
5876                b'a'..=b'z' => Some((c - b'a' + 26) as u32),
5877                b'0'..=b'9' => Some((c - b'0' + 52) as u32),
5878                b'-' => Some(62),
5879                b'_' => Some(63),
5880                _ => None,
5881            }
5882        }
5883        let bytes = input.as_bytes();
5884        let mut out = Vec::with_capacity(input.len() / 4 * 3 + 2);
5885        for chunk in bytes.chunks(4) {
5886            let mut n = 0u32;
5887            let mut valid = 0;
5888            for (i, &c) in chunk.iter().enumerate() {
5889                n |= val(c)? << (18 - 6 * i);
5890                valid += 1;
5891            }
5892            out.push((n >> 16) as u8);
5893            if valid > 2 {
5894                out.push((n >> 8) as u8);
5895            }
5896            if valid > 3 {
5897                out.push(n as u8);
5898            }
5899        }
5900        Some(out)
5901    }
5902
5903    // -- HMAC-SHA256, std-only -------------------------------------------------
5904
5905    /// HMAC-SHA256(key, msg) as lowercase hex.
5906    fn hmac_sha256_hex(key: &[u8], msg: &[u8]) -> String {
5907        const BLOCK: usize = 64;
5908        let mut k = [0u8; BLOCK];
5909        if key.len() > BLOCK {
5910            let d = sha256(key);
5911            k[..32].copy_from_slice(&d);
5912        } else {
5913            k[..key.len()].copy_from_slice(key);
5914        }
5915        let mut ipad = [0x36u8; BLOCK];
5916        let mut opad = [0x5cu8; BLOCK];
5917        for i in 0..BLOCK {
5918            ipad[i] ^= k[i];
5919            opad[i] ^= k[i];
5920        }
5921        let mut inner = Vec::with_capacity(BLOCK + msg.len());
5922        inner.extend_from_slice(&ipad);
5923        inner.extend_from_slice(msg);
5924        let inner_hash = sha256(&inner);
5925        let mut outer = Vec::with_capacity(BLOCK + 32);
5926        outer.extend_from_slice(&opad);
5927        outer.extend_from_slice(&inner_hash);
5928        let mac = sha256(&outer);
5929        let mut hex = String::with_capacity(64);
5930        for b in mac {
5931            hex.push_str(&format!("{b:02x}"));
5932        }
5933        hex
5934    }
5935
5936    /// SHA-256 (FIPS 180-4), std-only.
5937    fn sha256(data: &[u8]) -> [u8; 32] {
5938        const K: [u32; 64] = [
5939            0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4,
5940            0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe,
5941            0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f,
5942            0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7,
5943            0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc,
5944            0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b,
5945            0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116,
5946            0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
5947            0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7,
5948            0xc67178f2,
5949        ];
5950        let mut h: [u32; 8] = [
5951            0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab,
5952            0x5be0cd19,
5953        ];
5954
5955        let bit_len = (data.len() as u64) * 8;
5956        let mut msg = data.to_vec();
5957        msg.push(0x80);
5958        while msg.len() % 64 != 56 {
5959            msg.push(0);
5960        }
5961        msg.extend_from_slice(&bit_len.to_be_bytes());
5962
5963        for block in msg.chunks(64) {
5964            let mut w = [0u32; 64];
5965            for i in 0..16 {
5966                w[i] = u32::from_be_bytes([
5967                    block[i * 4],
5968                    block[i * 4 + 1],
5969                    block[i * 4 + 2],
5970                    block[i * 4 + 3],
5971                ]);
5972            }
5973            for i in 16..64 {
5974                let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3);
5975                let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10);
5976                w[i] = w[i - 16]
5977                    .wrapping_add(s0)
5978                    .wrapping_add(w[i - 7])
5979                    .wrapping_add(s1);
5980            }
5981            let mut a = h;
5982            for i in 0..64 {
5983                let s1 = a[4].rotate_right(6) ^ a[4].rotate_right(11) ^ a[4].rotate_right(25);
5984                let ch = (a[4] & a[5]) ^ ((!a[4]) & a[6]);
5985                let t1 = a[7]
5986                    .wrapping_add(s1)
5987                    .wrapping_add(ch)
5988                    .wrapping_add(K[i])
5989                    .wrapping_add(w[i]);
5990                let s0 = a[0].rotate_right(2) ^ a[0].rotate_right(13) ^ a[0].rotate_right(22);
5991                let maj = (a[0] & a[1]) ^ (a[0] & a[2]) ^ (a[1] & a[2]);
5992                let t2 = s0.wrapping_add(maj);
5993                a[7] = a[6];
5994                a[6] = a[5];
5995                a[5] = a[4];
5996                a[4] = a[3].wrapping_add(t1);
5997                a[3] = a[2];
5998                a[2] = a[1];
5999                a[1] = a[0];
6000                a[0] = t1.wrapping_add(t2);
6001            }
6002            for i in 0..8 {
6003                h[i] = h[i].wrapping_add(a[i]);
6004            }
6005        }
6006
6007        let mut out = [0u8; 32];
6008        for (i, word) in h.iter().enumerate() {
6009            out[i * 4..i * 4 + 4].copy_from_slice(&word.to_be_bytes());
6010        }
6011        out
6012    }
6013
6014    #[cfg(test)]
6015    mod tests {
6016        use super::*;
6017
6018        #[test]
6019        fn sha256_known_vector() {
6020            let d = sha256(b"abc");
6021            let hex: String = d.iter().map(|b| format!("{b:02x}")).collect();
6022            assert_eq!(
6023                hex,
6024                "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
6025            );
6026        }
6027
6028        #[test]
6029        fn hmac_known_vector() {
6030            let mac = hmac_sha256_hex(b"Jefe", b"what do ya want for nothing?");
6031            assert_eq!(
6032                mac,
6033                "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843"
6034            );
6035        }
6036
6037        #[test]
6038        fn sign_verify_round_trips() {
6039            let secret = "test-secret";
6040            let sid = "9f2c-opaque-session-id";
6041            let cookie = sign_session(sid, secret);
6042            let pair = cookie.split(';').next().unwrap().to_string();
6043            let mut headers = HeaderMap::new();
6044            headers.insert(axum::http::header::COOKIE, pair.parse().unwrap());
6045            assert_eq!(verify_session(&headers, secret).as_deref(), Some(sid));
6046            // Wrong secret → rejected (an attacker without the HMAC key can't forge).
6047            assert!(verify_session(&headers, "other-secret").is_none());
6048        }
6049
6050        #[test]
6051        fn forged_and_tampered_cookies_are_rejected() {
6052            let secret = "test-secret";
6053
6054            // 1. A fully forged cookie: attacker knows a victim's DID/sid but not
6055            //    the secret, so an arbitrary signature must not verify.
6056            let forged = format!(
6057                "{SESSION_COOKIE}={}.{}",
6058                b64url_encode(b"attacker-chosen-sid"),
6059                "deadbeef".repeat(8) // 64 hex chars, wrong sig
6060            );
6061            let mut headers = HeaderMap::new();
6062            headers.insert(axum::http::header::COOKIE, forged.parse().unwrap());
6063            assert!(verify_session(&headers, secret).is_none());
6064
6065            // 2. A tampered cookie: take a VALID cookie and mutate the sid while
6066            //    keeping the original signature — must not verify.
6067            let cookie = sign_session("real-sid", secret);
6068            let pair = cookie.split(';').next().unwrap();
6069            let (_b64, sig) = pair.split_once('=').unwrap().1.split_once('.').unwrap();
6070            let tampered = format!(
6071                "{SESSION_COOKIE}={}.{}",
6072                b64url_encode(b"different-sid"),
6073                sig
6074            );
6075            let mut headers2 = HeaderMap::new();
6076            headers2.insert(axum::http::header::COOKIE, tampered.parse().unwrap());
6077            assert!(verify_session(&headers2, secret).is_none());
6078        }
6079
6080        #[test]
6081        fn b64url_round_trips() {
6082            for s in ["did:plc:abc", "", "a", "ab", "abc", "abcd"] {
6083                let enc = b64url_encode(s.as_bytes());
6084                assert_eq!(b64url_decode(&enc).unwrap(), s.as_bytes());
6085            }
6086        }
6087    }
6088}
6089
6090// ---------------------------------------------------------------------------
6091// Small store helpers local to the web layer
6092// ---------------------------------------------------------------------------
6093
6094/// Fetch a single cached entry by id — SCOPED to `did`'s subscriptions.
6095///
6096/// Returns `None` (→ 404 at the handler) if the entry does not exist OR if
6097/// `did` does not subscribe to its feed. This is the per-DID read gate for the
6098/// `GET /entries/:id` reader and the htmx row rebuild: the shared cache is
6099/// deduped by URL, but no DID can read another DID's cached article.
6100///
6101/// **The only `SELECT e.*` left, and deliberately so.** This is the one surface
6102/// that renders `content_html`, and it fetches exactly one row. The list views
6103/// go through [`store::list_entries`], which is both paged and body-free — see
6104/// [`store::EntryListRow`] for why they had to stop sharing this projection.
6105async fn get_entry_by_id(
6106    pool: &store::Pool,
6107    did: &str,
6108    id: i64,
6109) -> anyhow::Result<Option<store::Entry>> {
6110    let entry = sqlx::query_as::<_, store::Entry>(
6111        r#"
6112        SELECT e.* FROM entries e
6113        WHERE e.id = ?2
6114          AND EXISTS (
6115              SELECT 1 FROM sub_ref sr
6116              WHERE sr.did = ?1 AND sr.feed_id = e.feed_id
6117          )
6118        "#,
6119    )
6120    .bind(did)
6121    .bind(id)
6122    .fetch_optional(pool)
6123    .await?;
6124    Ok(entry)
6125}
6126
6127/// Whether `entry_id` is marked read for `did` (absent state row = unread).
6128async fn entry_is_read(pool: &store::Pool, did: &str, entry_id: i64) -> anyhow::Result<bool> {
6129    let read: Option<bool> =
6130        sqlx::query_scalar("SELECT read FROM entry_state WHERE did = ?1 AND entry_id = ?2")
6131            .bind(did)
6132            .bind(entry_id)
6133            .fetch_optional(pool)
6134            .await?
6135            .flatten();
6136    Ok(read.unwrap_or(false))
6137}
6138
6139/// Whether `entry_id` is starred for `did` (absent state row = not starred).
6140async fn entry_is_starred(pool: &store::Pool, did: &str, entry_id: i64) -> anyhow::Result<bool> {
6141    let starred: Option<bool> =
6142        sqlx::query_scalar("SELECT starred FROM entry_state WHERE did = ?1 AND entry_id = ?2")
6143            .bind(did)
6144            .bind(entry_id)
6145            .fetch_optional(pool)
6146            .await?
6147            .flatten();
6148    Ok(starred.unwrap_or(false))
6149}
6150
6151/// Feed display title for one entry's feed id (via a single lookup).
6152async fn feed_title_by_entry(pool: &store::Pool, feed_id: i64) -> String {
6153    match sqlx::query_as::<_, store::Feed>("SELECT * FROM feeds WHERE id = ?1")
6154        .bind(feed_id)
6155        .fetch_optional(pool)
6156        .await
6157    {
6158        Ok(Some(f)) => display_title(f.title.as_deref(), &f.url),
6159        _ => String::new(),
6160    }
6161}
6162
6163/// Rebuild an [`EntryRow`] for an htmx swap after a read/star toggle. `read` may
6164/// be forced (mark-read path) or looked up (`None` — star path).
6165async fn build_entry_row(
6166    pool: &store::Pool,
6167    did: &str,
6168    id: i64,
6169    read: Option<bool>,
6170) -> anyhow::Result<Option<EntryRow>> {
6171    let entry = match get_entry_by_id(pool, did, id).await? {
6172        Some(e) => e,
6173        None => return Ok(None),
6174    };
6175    let read = match read {
6176        Some(r) => r,
6177        None => entry_is_read(pool, did, id).await?,
6178    };
6179    let starred = entry_is_starred(pool, did, id).await?;
6180    Ok(Some(EntryRow {
6181        id: entry.id,
6182        title: entry
6183            .title
6184            .clone()
6185            .filter(|t| !t.trim().is_empty())
6186            .unwrap_or_else(|| "(untitled)".to_string()),
6187        feed_title: feed_title_by_entry(pool, entry.feed_id).await,
6188        published: display_date(entry.published.as_deref()),
6189        read,
6190        starred,
6191        link: SafeLink::entry(id, ""),
6192        cached: true,
6193        rkey: String::new(),
6194    }))
6195}
6196
6197/// RFC3339 "now" (UTC) — shared by handlers that stamp/compare timestamps.
6198fn now_rfc3339() -> String {
6199    chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
6200}
6201
6202#[cfg(test)]
6203mod tests {
6204    use super::*;
6205
6206    #[test]
6207    fn qenc_encodes_reserved() {
6208        assert_eq!(qenc("a b"), "a%20b");
6209        assert_eq!(
6210            qenc("https://example.com/feed.xml"),
6211            "https%3A%2F%2Fexample.com%2Ffeed.xml"
6212        );
6213        assert_eq!(
6214            qenc("at://did:plc:x/c/r"),
6215            "at%3A%2F%2Fdid%3Aplc%3Ax%2Fc%2Fr"
6216        );
6217        // Unreserved chars pass through untouched.
6218        assert_eq!(qenc("A-Za-z0-9-_.~"), "A-Za-z0-9-_.~");
6219    }
6220
6221    #[test]
6222    fn folder_uri_shape() {
6223        assert_eq!(
6224            folder_uri("did:plc:abc", "3kfolder"),
6225            "at://did:plc:abc/community.lexicon.rss.folder/3kfolder"
6226        );
6227    }
6228
6229    // -- public-feeds-only: private/paid feeds are refused --------------------
6230
6231    #[test]
6232    fn private_feeds_are_classified_private_across_providers() {
6233        // The add + OPML paths both gate on this classifier; assert it flags a
6234        // spread of paid providers (newsletters + private podcasts) and the
6235        // generic credential-in-URL shapes.
6236        for url in [
6237            "https://author.substack.com/feed/private/deadbeefcafe1234",
6238            "https://www.patreon.com/rss/author?auth=Zm9vYmFyc2VjcmV0dG9rZW4",
6239            "https://blog.ghost.io/rss/?uuid=1f2e3d4c-5b6a-7089-90ab-cdef01234567",
6240            "https://feeds.supportingcast.fm/show/abcdef0123456789abcdef01",
6241            "https://example.com/feed?token=Zm9vYmFyc2VjcmV0",
6242            "https://user:pass@example.com/feed",
6243        ] {
6244            assert!(
6245                feed::classify_feed_privacy(url).is_private(),
6246                "expected private: {url}"
6247            );
6248        }
6249    }
6250
6251    #[test]
6252    fn public_feeds_stay_public() {
6253        for url in [
6254            "https://author.substack.com/feed",
6255            "https://wordpress.example.com/feed/",
6256            "https://example.com/rss.xml",
6257            "https://example.org/atom.xml",
6258            // YouTube channel/playlist RSS is fully public — must not false-block.
6259            "https://www.youtube.com/feeds/videos.xml?channel_id=UC-lHJZR3Gqxm24_Vd_AJ5Yw",
6260            "https://www.youtube.com/feeds/videos.xml?playlist_id=PLFgquLnL59alCl_2TQvOiD5Vgm1",
6261        ] {
6262            assert!(
6263                !feed::classify_feed_privacy(url).is_private(),
6264                "expected public: {url}"
6265            );
6266        }
6267    }
6268
6269    #[test]
6270    fn private_feed_label_is_public_safe_host_only() {
6271        // The OPML skip report must never echo the secret path/query, only the host.
6272        let label =
6273            private_feed_label("https://author.substack.com/feed/private/deadbeefcafe1234token");
6274        assert_eq!(label, "author.substack.com");
6275        assert!(!label.contains("deadbeefcafe1234token"));
6276        assert!(!label.contains("/private/"));
6277        // An unparseable URL degrades to a generic label.
6278        assert_eq!(private_feed_label("not a url"), "a private feed");
6279    }
6280
6281    #[test]
6282    fn refusal_message_promises_nothing_stored() {
6283        assert!(PRIVATE_FEED_REFUSAL.contains("not saved or sent anywhere"));
6284        assert!(PRIVATE_FEED_REFUSAL.contains("public feeds"));
6285    }
6286
6287    #[test]
6288    fn scope_query_preserves_context() {
6289        let q = EntryQuery {
6290            feed: Some("https://example.com/feed.xml".to_string()),
6291            folder: None,
6292            view: Some("all".to_string()),
6293        };
6294        let s = scope_query(&q);
6295        assert!(s.contains("feed=https%3A%2F%2Fexample.com%2Ffeed.xml"));
6296        assert!(s.contains("view=all"));
6297
6298        // Default view is omitted.
6299        let q2 = EntryQuery {
6300            feed: None,
6301            folder: None,
6302            view: Some("unread".to_string()),
6303        };
6304        assert_eq!(scope_query(&q2), "");
6305    }
6306
6307    // -- closed-beta invite gate + rate-limit + cache-control ------------------
6308
6309    use axum::body::Body;
6310    use axum::http::Request;
6311    use tower::ServiceExt; // for `oneshot`
6312
6313    /// Build an [`AppState`] over a fresh in-memory DB, seeding the given admin
6314    /// DIDs (via ALLOWED_DIDS → ensure_seed) and a fixed cookie secret so tests
6315    /// can forge matching cookies.
6316    async fn test_state(allowed: &[&str]) -> AppState {
6317        let db = store::init_url("sqlite::memory:").await.unwrap();
6318        let dids: Vec<String> = allowed.iter().map(|s| s.to_string()).collect();
6319        store::ensure_seed(&db, &dids).await.unwrap();
6320        let config = Config {
6321            allowed_dids: dids,
6322            cookie_secret: "test-cookie-secret-000".to_string(),
6323            beta_cap: 3,
6324            ..Config::default()
6325        };
6326        AppState::new(config, db).unwrap()
6327    }
6328
6329    /// A `Cookie` header carrying a valid signed session for `sid` (the sid is
6330    /// looked up in the registry, so create the session first).
6331    fn session_cookie(state: &AppState, did: &str, handle: Option<&str>) -> String {
6332        let sid = state.sessions.create(Session {
6333            did: did.to_string(),
6334            handle: handle.map(str::to_string),
6335        });
6336        let sc = cookie::sign_session(&sid, &state.config.cookie_secret);
6337        sc.split(';').next().unwrap().to_string()
6338    }
6339
6340    /// The bucket map is bounded by COUNT, not only by idle time. An hour is a
6341    /// long time to accept distinct source IPs on two unauthenticated guarded
6342    /// routes.
6343    #[test]
6344    fn the_rate_limit_map_is_bounded() {
6345        let rl = RateLimiter::shared();
6346        let now = Instant::now();
6347        for i in 0..(MAX_RATE_BUCKETS + 2_000) {
6348            // Distinct IPv6 addresses, all "seen" at increasing times so the LRU
6349            // ordering below is well-defined.
6350            let ip: IpAddr = format!("2001:db8::{i:x}").parse().unwrap();
6351            rl.check_at(ip, now + Duration::from_millis(i as u64));
6352        }
6353        let len = rl.inner.lock().unwrap().buckets.len();
6354        assert!(
6355            len <= MAX_RATE_BUCKETS,
6356            "the rate-limit map grew to {len}, past its {MAX_RATE_BUCKETS} cap"
6357        );
6358    }
6359
6360    /// Eviction must not hand a throttled attacker a fresh burst.
6361    ///
6362    /// The bound is LRU, so the one bucket an attacker can never evict is their
6363    /// own — it is the most recently touched thing in the map. If this inverted,
6364    /// the size cap would become a rate-limit bypass: spray addresses until the
6365    /// map overflows, then resume.
6366    #[test]
6367    fn flooding_the_map_does_not_reset_the_flooders_own_bucket() {
6368        let rl = RateLimiter::shared();
6369        let base = Instant::now();
6370        let attacker: IpAddr = "203.0.113.7".parse().unwrap();
6371        // Nanosecond steps: enough to keep the LRU ordering strictly increasing,
6372        // far too little for `RATE_REFILL_PER_SEC` to hand back a token. A
6373        // millisecond step made the whole flood take a second, and the refill —
6374        // working correctly — then looked exactly like an eviction bypass.
6375        let at = |n: u64| base + Duration::from_nanos(n);
6376
6377        // Spend the burst. `RATE_BURST` allowed, then refused.
6378        for i in 0..(RATE_BURST as u64) {
6379            assert!(rl.check_at(attacker, at(i)));
6380        }
6381        assert!(
6382            !rl.check_at(attacker, at(RATE_BURST as u64)),
6383            "burst was not exhausted; the rest of this test proves nothing"
6384        );
6385
6386        // Now overflow the map from other addresses, interleaving the attacker
6387        // so their bucket stays hot — the realistic shape of the attack.
6388        for i in 0..(MAX_RATE_BUCKETS + 2_000) {
6389            let t = at(100 + i as u64 * 2);
6390            let ip: IpAddr = format!("2001:db8:1::{i:x}").parse().unwrap();
6391            rl.check_at(ip, t);
6392            assert!(
6393                !rl.check_at(attacker, t),
6394                "the attacker got a token back after evictions at i={i}"
6395            );
6396        }
6397    }
6398
6399    /// The idle sweep is amortised, not per-request. It used to be an O(n) scan
6400    /// of the whole map on every guarded request, on one shared core.
6401    #[test]
6402    fn the_idle_sweep_does_not_run_on_every_request() {
6403        let rl = RateLimiter::shared();
6404        let start = Instant::now();
6405        let a: IpAddr = "198.51.100.1".parse().unwrap();
6406        let b: IpAddr = "198.51.100.2".parse().unwrap();
6407
6408        rl.check_at(a, start);
6409        // `b` arrives an hour later: `a` is now idle past `RATE_IDLE_EVICT`, but
6410        // the sweep interval has elapsed too, so this request does sweep it.
6411        rl.check_at(b, start + RATE_IDLE_EVICT + Duration::from_secs(1));
6412        assert!(
6413            !rl.inner.lock().unwrap().buckets.contains_key(&a),
6414            "an idle bucket survived a sweep that was due"
6415        );
6416
6417        // A second request moments later must NOT re-sweep — `b` is still there,
6418        // and the recorded sweep time must not have moved.
6419        let before = rl.inner.lock().unwrap().last_sweep;
6420        rl.check_at(b, start + RATE_IDLE_EVICT + Duration::from_secs(2));
6421        assert_eq!(
6422            rl.inner.lock().unwrap().last_sweep,
6423            before,
6424            "the sweep ran again within the interval"
6425        );
6426    }
6427
6428    #[test]
6429    fn rate_limited_paths_match_expected() {
6430        use axum::http::Method;
6431        assert!(is_rate_limited_path("/login", &Method::GET));
6432        assert!(is_rate_limited_path("/login", &Method::POST));
6433        assert!(is_rate_limited_path("/beta/redeem", &Method::POST));
6434        assert!(is_rate_limited_path("/subscriptions", &Method::POST));
6435        assert!(is_rate_limited_path("/opml", &Method::POST));
6436        assert!(is_rate_limited_path("/read-all", &Method::POST));
6437        assert!(is_rate_limited_path("/admin/invites", &Method::POST));
6438        assert!(is_rate_limited_path("/entries/42/read", &Method::POST));
6439        assert!(is_rate_limited_path("/entries/42/star", &Method::POST));
6440        // Read-only navigation is NOT limited.
6441        assert!(!is_rate_limited_path("/", &Method::GET));
6442        assert!(!is_rate_limited_path("/about", &Method::GET));
6443        assert!(!is_rate_limited_path("/entries/42", &Method::GET));
6444        assert!(!is_rate_limited_path("/login", &Method::HEAD));
6445    }
6446
6447    #[test]
6448    fn rate_limiter_allows_burst_then_429s() {
6449        let rl = RateLimiter::shared();
6450        let ip: IpAddr = "203.0.113.7".parse().unwrap();
6451        // The full burst passes.
6452        for _ in 0..(RATE_BURST as usize) {
6453            assert!(rl.check(ip));
6454        }
6455        // The next one (no time elapsed → no refill) is rejected.
6456        assert!(!rl.check(ip));
6457        // A different IP has its own bucket.
6458        let ip2: IpAddr = "203.0.113.8".parse().unwrap();
6459        assert!(rl.check(ip2));
6460    }
6461
6462    #[test]
6463    fn client_ip_ignores_spoofed_xff_without_trusted_header() {
6464        // With NO trusted header configured, a client-supplied X-Forwarded-For
6465        // must be ignored entirely — the limiter keys on the real socket peer,
6466        // so an attacker can't mint a fresh bucket per forged XFF value.
6467        let mut h = HeaderMap::new();
6468        h.insert("x-forwarded-for", "198.51.100.9, 10.0.0.1".parse().unwrap());
6469        let sock: SocketAddr = "203.0.113.55:1234".parse().unwrap();
6470        assert_eq!(
6471            client_ip(&h, Some(&sock), None),
6472            Some("203.0.113.55".parse().unwrap()),
6473            "spoofed XFF must not override the socket peer"
6474        );
6475    }
6476
6477    #[test]
6478    fn client_ip_uses_trusted_header_last_hop() {
6479        // With a trusted proxy header configured, the client IP comes from THAT
6480        // header (the proxy overwrites any client copy). On a comma list we take
6481        // the RIGHT-most hop — the one the trusted proxy appended — so a
6482        // client-forged left-most value is ignored.
6483        let sock: SocketAddr = "10.0.0.1:1234".parse().unwrap();
6484
6485        let mut h = HeaderMap::new();
6486        h.insert("fly-client-ip", "198.51.100.9".parse().unwrap());
6487        assert_eq!(
6488            client_ip(&h, Some(&sock), Some("fly-client-ip")),
6489            Some("198.51.100.9".parse().unwrap())
6490        );
6491
6492        // Attacker prepends a forged hop; the trusted proxy appends the real one.
6493        let mut h2 = HeaderMap::new();
6494        h2.insert("x-forwarded-for", "1.2.3.4, 198.51.100.9".parse().unwrap());
6495        assert_eq!(
6496            client_ip(&h2, Some(&sock), Some("x-forwarded-for")),
6497            Some("198.51.100.9".parse().unwrap()),
6498            "must take the right-most (trusted) hop, not the forged left-most"
6499        );
6500
6501        // Trusted header absent → fall back to the socket peer.
6502        let h3 = HeaderMap::new();
6503        assert_eq!(
6504            client_ip(&h3, Some(&sock), Some("fly-client-ip")),
6505            Some("10.0.0.1".parse().unwrap())
6506        );
6507    }
6508
6509    #[test]
6510    fn invite_cookie_round_trips_and_rejects_tamper() {
6511        let secret = "test-cookie-secret-000";
6512        let sc = sign_invite("FEATHER-ABCDWXYZ", secret);
6513        let pair = sc.split(';').next().unwrap();
6514        let mut h = HeaderMap::new();
6515        h.insert(header::COOKIE, pair.parse().unwrap());
6516        assert_eq!(
6517            invite_cookie_code(&h, secret).as_deref(),
6518            Some("FEATHER-ABCDWXYZ")
6519        );
6520        // Wrong secret → rejected.
6521        assert!(invite_cookie_code(&h, "other").is_none());
6522    }
6523
6524    #[tokio::test]
6525    async fn preflight_valid_expired_and_full() {
6526        let state = test_state(&["did:plc:admin"]).await;
6527        // A minted, active code preflights OK.
6528        let code = store::mint_code(&state.db, "did:plc:admin", 3600)
6529            .await
6530            .unwrap();
6531        assert!(preflight_code(&state, &code).await.is_ok());
6532
6533        // A code whose expiry is in the past preflights as Expired. (mint_code
6534        // clamps negative ttl to 0, so back-date the row directly for a
6535        // deterministic past expiry.)
6536        let expired = store::mint_code(&state.db, "did:plc:admin", 3600)
6537            .await
6538            .unwrap();
6539        sqlx::query("UPDATE invite_codes SET expires_at = ?1 WHERE code = ?2")
6540            .bind(chrono::Utc::now().timestamp() - 3600)
6541            .bind(&expired)
6542            .execute(&state.db)
6543            .await
6544            .unwrap();
6545        assert_eq!(
6546            preflight_code(&state, &expired).await,
6547            Err(store::RedeemError::Expired)
6548        );
6549
6550        // Unknown code → NotFound.
6551        assert_eq!(
6552            preflight_code(&state, "FEATHER-NOPENOPE").await,
6553            Err(store::RedeemError::NotFound)
6554        );
6555
6556        // Fill to cap (cap=3; the admin seed already took 1 seat) then preflight
6557        // must report CapacityFull.
6558        store::grant_access(&state.db, "did:plc:b", None, "admin", None)
6559            .await
6560            .unwrap();
6561        store::grant_access(&state.db, "did:plc:c", None, "admin", None)
6562            .await
6563            .unwrap();
6564        assert_eq!(store::count_beta_access(&state.db).await.unwrap(), 3);
6565        assert_eq!(
6566            preflight_code(&state, &code).await,
6567            Err(store::RedeemError::CapacityFull)
6568        );
6569    }
6570
6571    // -- Bot claim link + shared-secret mint ---------------------------------
6572
6573    /// A test state with a configured bot secret (so `/bot/claims` is live).
6574    async fn bot_state(bot_secret: &str) -> AppState {
6575        let db = store::init_url("sqlite::memory:").await.unwrap();
6576        store::ensure_seed(&db, &["did:plc:admin".to_string()])
6577            .await
6578            .unwrap();
6579        let config = Config {
6580            allowed_dids: vec!["did:plc:admin".to_string()],
6581            cookie_secret: "test-cookie-secret-000".to_string(),
6582            beta_cap: 3,
6583            bot_secret: Some(bot_secret.to_string()),
6584            public_url: "https://feather-reader.com".to_string(),
6585            ..Config::default()
6586        };
6587        AppState::new(config, db).unwrap()
6588    }
6589
6590    #[test]
6591    fn claim_token_round_trips_and_rejects_tamper() {
6592        let secret = "test-cookie-secret-000";
6593        let token = sign_claim_token("FEATHER-ABCDWXYZ", secret);
6594        // No cookie framing — a bare URL-safe token.
6595        assert!(!token.contains(';'));
6596        assert_eq!(
6597            claim_token_code(&token, secret).as_deref(),
6598            Some("FEATHER-ABCDWXYZ")
6599        );
6600        // Wrong secret → rejected.
6601        assert!(claim_token_code(&token, "other").is_none());
6602        // Tampered token → rejected.
6603        let mut bad = token.clone();
6604        bad.push('x');
6605        assert!(claim_token_code(&bad, secret).is_none());
6606        // The token is NOT confidential: it is `b64url(code).<sig>`, so the code is
6607        // only base64-obscured (not verbatim, but TRIVIALLY decodable — anyone can
6608        // recover it WITHOUT the secret). The security is single-use + HMAC
6609        // integrity + rate-limit, not secrecy of the code. Assert the code half is
6610        // publicly decodable (a plain base64url decode, no secret involved).
6611        let (b64, _sig) = token.split_once('.').expect("token is b64.sig");
6612        assert_eq!(
6613            test_b64url_decode(b64).as_deref(),
6614            Some("FEATHER-ABCDWXYZ".as_bytes()),
6615            "the code half of the token is plain base64url, decodable by anyone"
6616        );
6617    }
6618
6619    /// Minimal URL-safe base64 (no padding) decoder for the test above, proving the
6620    /// claim token's code half needs NO secret to recover (it is not confidential).
6621    fn test_b64url_decode(input: &str) -> Option<Vec<u8>> {
6622        fn val(c: u8) -> Option<u32> {
6623            match c {
6624                b'A'..=b'Z' => Some((c - b'A') as u32),
6625                b'a'..=b'z' => Some((c - b'a' + 26) as u32),
6626                b'0'..=b'9' => Some((c - b'0' + 52) as u32),
6627                b'-' => Some(62),
6628                b'_' => Some(63),
6629                _ => None,
6630            }
6631        }
6632        let mut out = Vec::with_capacity(input.len() / 4 * 3);
6633        for chunk in input.as_bytes().chunks(4) {
6634            let mut n = 0u32;
6635            let mut bits = 0;
6636            for &c in chunk {
6637                n = (n << 6) | val(c)?;
6638                bits += 6;
6639            }
6640            let bytes = bits / 8;
6641            n <<= 24 - bits;
6642            for i in 0..bytes {
6643                out.push((n >> (16 - i * 8)) as u8);
6644            }
6645        }
6646        Some(out)
6647    }
6648
6649    #[tokio::test]
6650    async fn bot_mint_then_claim_grants_a_seat() {
6651        let state = bot_state("bot-secret-abcdef").await;
6652        let app = router(state.clone());
6653
6654        // 1. Mint a claim via the shared-secret endpoint.
6655        let resp = app
6656            .clone()
6657            .oneshot(
6658                Request::builder()
6659                    .method("POST")
6660                    .uri("/bot/claims")
6661                    .header("x-bot-secret", "bot-secret-abcdef")
6662                    .body(Body::empty())
6663                    .unwrap(),
6664            )
6665            .await
6666            .unwrap();
6667        assert_eq!(resp.status(), StatusCode::OK);
6668        let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
6669            .await
6670            .unwrap();
6671        let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
6672        let token = json["token"].as_str().unwrap().to_string();
6673        let url = json["url"].as_str().unwrap();
6674        assert!(url.starts_with("https://feather-reader.com/claim?t="));
6675        // The raw code is returned for the bot's records but not embedded in url.
6676        assert!(json["code"].as_str().unwrap().starts_with("FEATHER-"));
6677        assert!(!url.contains("FEATHER-"));
6678
6679        // 2. Follow the claim link → reserves the invite cookie + redirects to /login.
6680        let resp = app
6681            .clone()
6682            .oneshot(
6683                Request::builder()
6684                    .method("GET")
6685                    .uri(format!("/claim?t={}", qenc(&token)))
6686                    .body(Body::empty())
6687                    .unwrap(),
6688            )
6689            .await
6690            .unwrap();
6691        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
6692        assert_eq!(resp.headers().get(header::LOCATION).unwrap(), "/login");
6693        let set_cookie = resp
6694            .headers()
6695            .get(header::SET_COOKIE)
6696            .unwrap()
6697            .to_str()
6698            .unwrap();
6699        assert!(set_cookie.starts_with(INVITE_COOKIE), "{set_cookie}");
6700
6701        // 3. The reserved cookie carries the same code the token wrapped, and
6702        //    redeeming it (the callback's machinery) grants a seat.
6703        let code = claim_token_code(&token, &state.config.cookie_secret).unwrap();
6704        let out = store::redeem_code(
6705            &state.db,
6706            &code,
6707            "did:plc:follower",
6708            None,
6709            state.config.beta_cap,
6710        )
6711        .await
6712        .unwrap();
6713        assert_eq!(out, Ok(()));
6714        assert!(store::has_beta_access(&state.db, "did:plc:follower")
6715            .await
6716            .unwrap());
6717    }
6718
6719    #[tokio::test]
6720    async fn claim_with_invalid_token_bounces() {
6721        let state = bot_state("bot-secret-abcdef").await;
6722        let app = router(state);
6723        let resp = app
6724            .oneshot(
6725                Request::builder()
6726                    .method("GET")
6727                    .uri("/claim?t=not-a-real-token")
6728                    .body(Body::empty())
6729                    .unwrap(),
6730            )
6731            .await
6732            .unwrap();
6733        // Renders the invite page (200), NOT a redirect to /login.
6734        assert_eq!(resp.status(), StatusCode::OK);
6735    }
6736
6737    #[tokio::test]
6738    async fn claim_with_used_token_is_refused() {
6739        let state = bot_state("bot-secret-abcdef").await;
6740        // Mint a code + wrap it, then redeem it out from under the token.
6741        let code = store::mint_code(&state.db, "did:plc:admin", 3600)
6742            .await
6743            .unwrap();
6744        let token = sign_claim_token(&code, &state.config.cookie_secret);
6745        store::redeem_code(
6746            &state.db,
6747            &code,
6748            "did:plc:someone",
6749            None,
6750            state.config.beta_cap,
6751        )
6752        .await
6753        .unwrap()
6754        .unwrap();
6755        let app = router(state);
6756        let resp = app
6757            .oneshot(
6758                Request::builder()
6759                    .method("GET")
6760                    .uri(format!("/claim?t={}", qenc(&token)))
6761                    .body(Body::empty())
6762                    .unwrap(),
6763            )
6764            .await
6765            .unwrap();
6766        // A used code → the invite page (AlreadyRedeemed), not a fresh reservation.
6767        assert_eq!(resp.status(), StatusCode::OK);
6768        assert!(resp.headers().get(header::SET_COOKIE).is_none());
6769    }
6770
6771    #[tokio::test]
6772    async fn bot_claims_rejects_bad_and_missing_secret() {
6773        let state = bot_state("bot-secret-abcdef").await;
6774        let app = router(state);
6775        // Wrong secret.
6776        let resp = app
6777            .clone()
6778            .oneshot(
6779                Request::builder()
6780                    .method("POST")
6781                    .uri("/bot/claims")
6782                    .header("x-bot-secret", "wrong")
6783                    .body(Body::empty())
6784                    .unwrap(),
6785            )
6786            .await
6787            .unwrap();
6788        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
6789        // Missing secret.
6790        let resp = app
6791            .oneshot(
6792                Request::builder()
6793                    .method("POST")
6794                    .uri("/bot/claims")
6795                    .body(Body::empty())
6796                    .unwrap(),
6797            )
6798            .await
6799            .unwrap();
6800        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
6801    }
6802
6803    #[tokio::test]
6804    async fn bot_claims_disabled_when_secret_unset() {
6805        // test_state configures NO bot secret → the endpoint is off (503).
6806        let state = test_state(&["did:plc:admin"]).await;
6807        let app = router(state);
6808        let resp = app
6809            .oneshot(
6810                Request::builder()
6811                    .method("POST")
6812                    .uri("/bot/claims")
6813                    .header("x-bot-secret", "anything")
6814                    .body(Body::empty())
6815                    .unwrap(),
6816            )
6817            .await
6818            .unwrap();
6819        assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
6820    }
6821
6822    #[tokio::test]
6823    async fn bot_claims_refuses_at_capacity() {
6824        let state = bot_state("bot-secret-abcdef").await;
6825        // cap=3, admin seed took 1 seat. Grant 2 more to fill it.
6826        store::grant_access(&state.db, "did:plc:b", None, "admin", None)
6827            .await
6828            .unwrap();
6829        store::grant_access(&state.db, "did:plc:c", None, "admin", None)
6830            .await
6831            .unwrap();
6832        assert_eq!(store::count_beta_access(&state.db).await.unwrap(), 3);
6833        let app = router(state);
6834        let resp = app
6835            .oneshot(
6836                Request::builder()
6837                    .method("POST")
6838                    .uri("/bot/claims")
6839                    .header("x-bot-secret", "bot-secret-abcdef")
6840                    .body(Body::empty())
6841                    .unwrap(),
6842            )
6843            .await
6844            .unwrap();
6845        assert_eq!(resp.status(), StatusCode::CONFLICT);
6846        let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
6847            .await
6848            .unwrap();
6849        assert!(String::from_utf8_lossy(&bytes).contains("full"));
6850    }
6851
6852    #[tokio::test]
6853    async fn bot_claims_counts_outstanding_codes_against_cap() {
6854        let state = bot_state("bot-secret-abcdef").await;
6855        // cap=3, admin seed = 1 seat. Two outstanding active codes = 3 committed.
6856        store::mint_code(&state.db, "did:plc:admin", 3600)
6857            .await
6858            .unwrap();
6859        store::mint_code(&state.db, "did:plc:admin", 3600)
6860            .await
6861            .unwrap();
6862        let app = router(state);
6863        let resp = app
6864            .oneshot(
6865                Request::builder()
6866                    .method("POST")
6867                    .uri("/bot/claims")
6868                    .header("x-bot-secret", "bot-secret-abcdef")
6869                    .body(Body::empty())
6870                    .unwrap(),
6871            )
6872            .await
6873            .unwrap();
6874        // 1 seat + 2 outstanding >= cap 3 → refused even though only 1 real seat used.
6875        assert_eq!(resp.status(), StatusCode::CONFLICT);
6876    }
6877
6878    /// POST /bot/claims with a JSON body carrying the follower DID.
6879    async fn post_bot_claim_for(
6880        app: &axum::Router,
6881        secret: &str,
6882        did: &str,
6883    ) -> (StatusCode, serde_json::Value) {
6884        let resp = app
6885            .clone()
6886            .oneshot(
6887                Request::builder()
6888                    .method("POST")
6889                    .uri("/bot/claims")
6890                    .header("x-bot-secret", secret)
6891                    .header("content-type", "application/json")
6892                    .body(Body::from(format!(
6893                        "{{\"did\":\"{did}\",\"handle\":\"who.test\"}}"
6894                    )))
6895                    .unwrap(),
6896            )
6897            .await
6898            .unwrap();
6899        let status = resp.status();
6900        let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
6901            .await
6902            .unwrap();
6903        let json = if bytes.is_empty() {
6904            serde_json::Value::Null
6905        } else {
6906            serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null)
6907        };
6908        (status, json)
6909    }
6910
6911    #[tokio::test]
6912    async fn bot_claims_returns_already_seated_for_a_member() {
6913        // A DID that already holds beta access must get `already_seated` with NO
6914        // code/url — the bot posts nothing. This is the server-side backstop that
6915        // survives a bot-host state loss (it would otherwise re-mint + re-post).
6916        let state = bot_state("bot-secret-abcdef").await;
6917        store::grant_access(&state.db, "did:plc:member", None, "admin", None)
6918            .await
6919            .unwrap();
6920        let app = router(state.clone());
6921        let (status, json) = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:member").await;
6922        assert_eq!(status, StatusCode::OK);
6923        assert_eq!(json["status"], "already_seated");
6924        assert_eq!(json["code"], "");
6925        assert_eq!(json["url"], "");
6926        // No new invite code was minted for the seated DID.
6927        assert!(store::find_active_code_for_did(&state.db, "did:plc:member")
6928            .await
6929            .unwrap()
6930            .is_none());
6931    }
6932
6933    #[tokio::test]
6934    async fn bot_claims_is_idempotent_per_did_returns_same_code() {
6935        // Two mint requests for the SAME follower DID must return the SAME code
6936        // (the app is authoritative), never a second one — so a bot-host state loss
6937        // re-requesting cannot double-mint or double-post.
6938        let state = bot_state("bot-secret-abcdef").await;
6939        let app = router(state.clone());
6940
6941        let (s1, j1) = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:follower1").await;
6942        assert_eq!(s1, StatusCode::OK);
6943        assert_eq!(j1["status"], "minted");
6944        let code1 = j1["code"].as_str().unwrap().to_string();
6945
6946        let (s2, j2) = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:follower1").await;
6947        assert_eq!(s2, StatusCode::OK);
6948        assert_eq!(j2["status"], "existing");
6949        assert_eq!(j2["code"].as_str().unwrap(), code1, "same code returned");
6950        assert_eq!(j2["url"], j1["url"], "same url returned");
6951
6952        // Exactly ONE active code exists for that DID.
6953        assert_eq!(store::count_active_codes(&state.db).await.unwrap(), 1);
6954    }
6955
6956    #[tokio::test]
6957    async fn bot_claims_records_intended_did_at_mint() {
6958        // A fresh mint records the follower DID so the lookup finds it.
6959        let state = bot_state("bot-secret-abcdef").await;
6960        let app = router(state.clone());
6961        let (status, json) =
6962            post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:follower2").await;
6963        assert_eq!(status, StatusCode::OK);
6964        let code = json["code"].as_str().unwrap();
6965        assert_eq!(
6966            store::find_active_code_for_did(&state.db, "did:plc:follower2")
6967                .await
6968                .unwrap()
6969                .as_deref(),
6970            Some(code)
6971        );
6972    }
6973
6974    #[tokio::test]
6975    async fn bot_claims_concurrent_same_did_never_double_mints() {
6976        // S4: two concurrent /bot/claims for ONE follower DID must not both mint an
6977        // active code. The dedupe check (3b) and the mint are separate statements,
6978        // so a race can slip both past 3b's `Ok(None)`; the partial unique index
6979        // then makes the loser's INSERT conflict, and the handler recovers by
6980        // returning the winner's code (status `existing`) rather than 500-ing.
6981        // Result: exactly ONE active code, and BOTH callers get a usable code.
6982        let state = bot_state("bot-secret-abcdef").await;
6983        let app = router(state.clone());
6984
6985        let a = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:racer");
6986        let b = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:racer");
6987        let ((sa, ja), (sb, jb)) = tokio::join!(a, b);
6988
6989        assert_eq!(sa, StatusCode::OK, "first response: {ja:?}");
6990        assert_eq!(sb, StatusCode::OK, "second response: {jb:?}");
6991
6992        // Exactly one active code for the DID — the whole point of the fix.
6993        assert_eq!(
6994            store::count_active_codes(&state.db).await.unwrap(),
6995            1,
6996            "concurrent mints must not create two active codes"
6997        );
6998
6999        // Both callers received the SAME (single) code, and neither got a 500.
7000        let ca = ja["code"].as_str().unwrap_or("");
7001        let cb = jb["code"].as_str().unwrap_or("");
7002        assert!(!ca.is_empty() && !cb.is_empty(), "both must return a code");
7003        assert_eq!(ca, cb, "both callers must get the one minted code");
7004        // One is `minted` (the winner), the other `minted` or `existing` depending
7005        // on interleaving — but never an error status.
7006        for st in [&ja["status"], &jb["status"]] {
7007            let s = st.as_str().unwrap_or("");
7008            assert!(s == "minted" || s == "existing", "unexpected status {s:?}");
7009        }
7010    }
7011
7012    #[tokio::test]
7013    async fn bot_claims_rejects_malformed_json_body() {
7014        let state = bot_state("bot-secret-abcdef").await;
7015        let app = router(state);
7016        let resp = app
7017            .oneshot(
7018                Request::builder()
7019                    .method("POST")
7020                    .uri("/bot/claims")
7021                    .header("x-bot-secret", "bot-secret-abcdef")
7022                    .header("content-type", "application/json")
7023                    .body(Body::from("{not json"))
7024                    .unwrap(),
7025            )
7026            .await
7027            .unwrap();
7028        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
7029    }
7030
7031    #[tokio::test]
7032    async fn favicon_ico_served_at_root() {
7033        // Browsers request bare /favicon.ico regardless of the <link rel="icon">
7034        // tags in <head>; the root route must serve the icon, not 404.
7035        let state = test_state(&[]).await;
7036        let app = router(state);
7037        let resp = app
7038            .oneshot(
7039                Request::builder()
7040                    .uri("/favicon.ico")
7041                    .body(Body::empty())
7042                    .unwrap(),
7043            )
7044            .await
7045            .unwrap();
7046        assert_eq!(resp.status(), StatusCode::OK);
7047        let ct = resp
7048            .headers()
7049            .get(header::CONTENT_TYPE)
7050            .unwrap()
7051            .to_str()
7052            .unwrap();
7053        assert!(
7054            ct.contains("icon") || ct.starts_with("image/"),
7055            "content-type = {ct}"
7056        );
7057    }
7058
7059    #[tokio::test]
7060    async fn login_without_invite_redirects_to_beta_redeem() {
7061        // No allow-list seed, no invite cookie: starting OAuth must be refused.
7062        let state = test_state(&[]).await;
7063        let app = router(state);
7064        let resp = app
7065            .oneshot(
7066                Request::builder()
7067                    .method("POST")
7068                    .uri("/login")
7069                    .header("content-type", "application/x-www-form-urlencoded")
7070                    .body(Body::from("handle=alice.bsky.social"))
7071                    .unwrap(),
7072            )
7073            .await
7074            .unwrap();
7075        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
7076        assert_eq!(
7077            resp.headers().get(header::LOCATION).unwrap(),
7078            "/beta/redeem"
7079        );
7080    }
7081
7082    #[tokio::test]
7083    async fn login_with_valid_invite_cookie_starts_oauth() {
7084        let state = test_state(&[]).await;
7085        let cookie = sign_invite("FEATHER-ABCDWXYZ", &state.config.cookie_secret);
7086        let cookie = cookie.split(';').next().unwrap().to_string();
7087        let app = router(state);
7088        let resp = app
7089            .oneshot(
7090                Request::builder()
7091                    .method("POST")
7092                    .uri("/login")
7093                    .header("content-type", "application/x-www-form-urlencoded")
7094                    .header(header::COOKIE, cookie)
7095                    .body(Body::from("handle=alice.bsky.social"))
7096                    .unwrap(),
7097            )
7098            .await
7099            .unwrap();
7100        // Redirects into the sidecar login (not to /beta/redeem).
7101        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
7102        let loc = resp
7103            .headers()
7104            .get(header::LOCATION)
7105            .unwrap()
7106            .to_str()
7107            .unwrap();
7108        assert!(loc.contains("/login"), "loc = {loc}");
7109        assert_ne!(loc, "/beta/redeem");
7110    }
7111
7112    /// A resolver that always fails — proves the fast paths short-circuit BEFORE
7113    /// any network resolution and that a resolution failure fails closed.
7114    async fn resolver_never(_handle: String) -> Option<String> {
7115        None
7116    }
7117
7118    /// A resolver that maps every handle to `did`.
7119    fn resolver_to(did: &'static str) -> impl FnOnce(String) -> std::future::Ready<Option<String>> {
7120        move |_handle| std::future::ready(Some(did.to_string()))
7121    }
7122
7123    /// Path 3: a cookie-less visitor whose submitted handle resolves to a DID
7124    /// that already holds a seat (the seeded-admin first-login case) passes the
7125    /// gate — no session cookie, no invite code.
7126    #[tokio::test]
7127    async fn may_start_oauth_honors_seat_via_resolved_handle() {
7128        // The seeded admin holds a seat (via ALLOWED_DIDS → ensure_seed) but has
7129        // no cookie on a fresh deploy.
7130        let state = test_state(&["did:plc:admin"]).await;
7131        let headers = HeaderMap::new();
7132        assert!(
7133            may_start_oauth_with(
7134                &state,
7135                &headers,
7136                "admin.example",
7137                resolver_to("did:plc:admin")
7138            )
7139            .await,
7140            "a handle resolving to a seated DID must pass the gate"
7141        );
7142    }
7143
7144    /// Path 3, negative: a handle that resolves to a DID with NO seat is bounced
7145    /// — the anti-abuse intent is preserved (resolution succeeds, seat check
7146    /// fails).
7147    #[tokio::test]
7148    async fn may_start_oauth_bounces_non_member_handle() {
7149        let state = test_state(&["did:plc:admin"]).await;
7150        let headers = HeaderMap::new();
7151        assert!(
7152            !may_start_oauth_with(
7153                &state,
7154                &headers,
7155                "rando.example",
7156                resolver_to("did:plc:rando")
7157            )
7158            .await,
7159            "a resolved DID with no seat must be bounced"
7160        );
7161    }
7162
7163    /// Fail-closed: an unresolvable/malformed handle (resolver returns `None`)
7164    /// bounces gracefully — no panic, no handshake.
7165    #[tokio::test]
7166    async fn may_start_oauth_fails_closed_on_unresolvable_handle() {
7167        let state = test_state(&["did:plc:admin"]).await;
7168        let headers = HeaderMap::new();
7169        assert!(
7170            !may_start_oauth_with(&state, &headers, "not a handle", resolver_never).await,
7171            "an unresolvable handle must fail closed"
7172        );
7173    }
7174
7175    /// The session-cookie fast path admits a seated member WITHOUT calling the
7176    /// resolver (proven by injecting `resolver_never`, which would otherwise
7177    /// bounce).
7178    #[tokio::test]
7179    async fn may_start_oauth_session_cookie_shortcircuits_resolution() {
7180        let state = test_state(&[]).await;
7181        let did = "did:plc:member";
7182        store::grant_access(&state.db, did, Some("member.example"), "test", None)
7183            .await
7184            .unwrap();
7185        let cookie = session_cookie(&state, did, Some("member.example"));
7186        let mut headers = HeaderMap::new();
7187        headers.insert(header::COOKIE, cookie.parse().unwrap());
7188        assert!(
7189            may_start_oauth_with(&state, &headers, "member.example", resolver_never).await,
7190            "a seated session cookie must pass without resolution"
7191        );
7192    }
7193
7194    /// The invite-cookie fast path admits WITHOUT calling the resolver.
7195    #[tokio::test]
7196    async fn may_start_oauth_invite_cookie_shortcircuits_resolution() {
7197        let state = test_state(&[]).await;
7198        let cookie = sign_invite("FEATHER-ABCDWXYZ", &state.config.cookie_secret);
7199        let cookie = cookie.split(';').next().unwrap().to_string();
7200        let mut headers = HeaderMap::new();
7201        headers.insert(header::COOKIE, cookie.parse().unwrap());
7202        assert!(
7203            may_start_oauth_with(&state, &headers, "someone.example", resolver_never).await,
7204            "a valid invite cookie must pass without resolution"
7205        );
7206    }
7207
7208    #[tokio::test]
7209    async fn admin_mint_requires_admin_seed_did() {
7210        let state = test_state(&["did:plc:admin"]).await;
7211        // A non-admin (but beta'd) session is forbidden.
7212        store::grant_access(&state.db, "did:plc:rando", None, "test", None)
7213            .await
7214            .unwrap();
7215        let rando_cookie = session_cookie(&state, "did:plc:rando", None);
7216        // An admin session is allowed.
7217        let admin_cookie = session_cookie(&state, "did:plc:admin", None);
7218        let app = router(state);
7219
7220        let forbidden = app
7221            .clone()
7222            .oneshot(
7223                Request::builder()
7224                    .method("POST")
7225                    .uri("/admin/invites?n=2")
7226                    .header(header::COOKIE, rando_cookie)
7227                    .body(Body::empty())
7228                    .unwrap(),
7229            )
7230            .await
7231            .unwrap();
7232        assert_eq!(forbidden.status(), StatusCode::FORBIDDEN);
7233
7234        let ok = app
7235            .oneshot(
7236                Request::builder()
7237                    .method("POST")
7238                    .uri("/admin/invites?n=2")
7239                    .header(header::COOKIE, admin_cookie)
7240                    .body(Body::empty())
7241                    .unwrap(),
7242            )
7243            .await
7244            .unwrap();
7245        assert_eq!(ok.status(), StatusCode::OK);
7246        let bytes = axum::body::to_bytes(ok.into_body(), 64 * 1024)
7247            .await
7248            .unwrap();
7249        let body = String::from_utf8(bytes.to_vec()).unwrap();
7250        let minted: Vec<&str> = body.lines().filter(|l| !l.is_empty()).collect();
7251        assert_eq!(minted.len(), 2);
7252        assert!(minted.iter().all(|c| c.starts_with("FEATHER-")));
7253    }
7254
7255    #[tokio::test]
7256    async fn admin_mint_unauthenticated_is_401() {
7257        let state = test_state(&["did:plc:admin"]).await;
7258        let app = router(state);
7259        let resp = app
7260            .oneshot(
7261                Request::builder()
7262                    .method("POST")
7263                    .uri("/admin/invites")
7264                    .body(Body::empty())
7265                    .unwrap(),
7266            )
7267            .await
7268            .unwrap();
7269        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
7270    }
7271
7272    /// A state whose `/about` renders the adoption line, seeded with one
7273    /// observation.
7274    async fn adoption_state(repos: i64, truncated: bool) -> AppState {
7275        let db = store::init_url("sqlite::memory:").await.unwrap();
7276        store::record_network_stat(
7277            &db,
7278            &store::NetworkStat {
7279                key: store::ADOPTION_STAT_KEY.to_string(),
7280                source: "https://relay1.us-west.bsky.network".to_string(),
7281                value: repos,
7282                truncated,
7283                observed_at: "2026-08-13T04:05:06Z".to_string(),
7284            },
7285        )
7286        .await
7287        .unwrap();
7288        let config = Config {
7289            cookie_secret: "test-cookie-secret-000".to_string(),
7290            show_adoption: true,
7291            ..Config::default()
7292        };
7293        AppState::new(config, db).unwrap()
7294    }
7295
7296    async fn about_body(state: AppState) -> String {
7297        let resp = router(state)
7298            .oneshot(
7299                Request::builder()
7300                    .uri("/about")
7301                    .body(Body::empty())
7302                    .unwrap(),
7303            )
7304            .await
7305            .unwrap();
7306        assert_eq!(resp.status(), StatusCode::OK);
7307        let bytes = axum::body::to_bytes(resp.into_body(), 256 * 1024)
7308            .await
7309            .unwrap();
7310        String::from_utf8(bytes.to_vec()).unwrap()
7311    }
7312
7313    /// Default config ⇒ the flag is off ⇒ no line, and no query is issued.
7314    #[tokio::test]
7315    async fn about_omits_adoption_line_by_default() {
7316        let state = test_state(&[]).await;
7317        assert!(!state.config.show_adoption);
7318        let body = about_body(state).await;
7319        assert!(
7320            !body.contains("atproto network"),
7321            "the adoption line must not render by default"
7322        );
7323    }
7324
7325    #[tokio::test]
7326    async fn about_renders_adoption_line_when_enabled() {
7327        // **A distinctive count, and asserted IN ITS SENTENCE.**
7328        //
7329        // This used to seed 4 and assert `body.contains("4")`, which the
7330        // colophon's `width="44"` satisfies whatever the count is — so
7331        // hardcoding the rendered number passed. Both halves are needed: a
7332        // digit that does not occur incidentally, and the assertion tied to the
7333        // phrase it belongs to.
7334        let body = about_body(adoption_state(7_318, false).await).await;
7335        // The count and its phrase are on separate template lines, so compare
7336        // against a whitespace-collapsed copy rather than the raw HTML.
7337        let flat = body.split_whitespace().collect::<Vec<_>>().join(" ");
7338        assert!(
7339            flat.contains("7318 accounts on the atproto network hold"),
7340            "the count did not render in its own sentence: {flat}",
7341        );
7342        assert!(
7343            body.contains("accounts on the atproto network hold"),
7344            "{body}"
7345        );
7346        assert!(
7347            body.contains("2026-08-13"),
7348            "the observation date must render"
7349        );
7350        assert!(
7351            body.contains("lower bound"),
7352            "the non-archival caveat must ride along with the number"
7353        );
7354        assert!(
7355            !body.contains("At least"),
7356            "an untruncated count is exact-ish"
7357        );
7358    }
7359
7360    /// A count of one must read as "1 account … holds", not "1 accounts … hold".
7361    #[tokio::test]
7362    async fn about_adoption_line_is_singular_at_one() {
7363        let body = about_body(adoption_state(1, false).await).await;
7364        assert!(
7365            body.contains("account on the atproto network holds"),
7366            "{body}"
7367        );
7368    }
7369
7370    /// A truncated observation is a floor, and must say so.
7371    #[tokio::test]
7372    async fn about_adoption_line_says_at_least_when_truncated() {
7373        let body = about_body(adoption_state(25_000, true).await).await;
7374        assert!(body.contains("At least"), "{body}");
7375    }
7376
7377    /// Flag on but no observation (or a zero) ⇒ 200, no line, no error.
7378    #[tokio::test]
7379    async fn about_omits_line_when_enabled_with_no_observation() {
7380        let db = store::init_url("sqlite::memory:").await.unwrap();
7381        let config = Config {
7382            cookie_secret: "test-cookie-secret-000".to_string(),
7383            show_adoption: true,
7384            ..Config::default()
7385        };
7386        let body = about_body(AppState::new(config, db).unwrap()).await;
7387        assert!(!body.contains("atproto network"));
7388    }
7389
7390    // ---- standard.site on the public pages and the subscribe form ----------
7391    //
7392    // `FEATHERREADER_STANDARD_SITE` gates what may be STORED (`add_subscription`
7393    // refuses every `at://` paste with it off), so a page that tells the reader
7394    // to paste a publication URI is advertising a form that will be refused
7395    // unless the flag is on. These pin both halves: with the flag on the pages
7396    // say how; with it off they do not.
7397
7398    /// A state with the standard.site flag chosen, and `did` holding a seat so
7399    /// `/manage` renders for it.
7400    async fn standard_site_state(standard_site: bool, did: &str) -> AppState {
7401        let db = store::init_url("sqlite::memory:").await.unwrap();
7402        store::ensure_seed(&db, &[did.to_string()]).await.unwrap();
7403        let config = Config {
7404            allowed_dids: vec![did.to_string()],
7405            cookie_secret: "test-cookie-secret-000".to_string(),
7406            beta_cap: 3,
7407            standard_site,
7408            ..Config::default()
7409        };
7410        AppState::new(config, db).unwrap()
7411    }
7412
7413    /// `GET path` as `did`, asserted 200, body as a string.
7414    async fn signed_in_body(state: AppState, path: &str, did: &str) -> String {
7415        let cookie = session_cookie(&state, did, Some("reader.example"));
7416        let resp = router(state)
7417            .oneshot(
7418                Request::builder()
7419                    .uri(path)
7420                    .header(header::COOKIE, cookie)
7421                    .body(Body::empty())
7422                    .unwrap(),
7423            )
7424            .await
7425            .unwrap();
7426        assert_eq!(resp.status(), StatusCode::OK, "{path}");
7427        let bytes = axum::body::to_bytes(resp.into_body(), 512 * 1024)
7428            .await
7429            .unwrap();
7430        String::from_utf8(bytes.to_vec()).unwrap()
7431    }
7432
7433    /// `GET path` signed out, asserted 200, body as a string.
7434    async fn public_body(state: AppState, path: &str) -> String {
7435        let resp = router(state)
7436            .oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
7437            .await
7438            .unwrap();
7439        assert_eq!(resp.status(), StatusCode::OK, "{path}");
7440        let bytes = axum::body::to_bytes(resp.into_body(), 512 * 1024)
7441            .await
7442            .unwrap();
7443        String::from_utf8(bytes.to_vec()).unwrap()
7444    }
7445
7446    /// The `<input … id="feed-url" …>` tag of the subscribe form, whole.
7447    fn feed_url_input(body: &str) -> &str {
7448        let start = body
7449            .find("id=\"feed-url\"")
7450            .and_then(|i| body[..i].rfind("<input"))
7451            .expect("the subscribe form's URL input renders");
7452        let end = body[start..].find('>').expect("the input tag closes") + start + 1;
7453        &body[start..end]
7454    }
7455
7456    /// Flag on: the subscribe form says a publication URI is accepted, and shows
7457    /// both spellings the handler takes (DID and handle).
7458    #[tokio::test]
7459    async fn manage_hints_at_publications_when_the_flag_is_on() {
7460        let did = "did:plc:reader";
7461        let body = signed_in_body(standard_site_state(true, did).await, "/manage", did).await;
7462        assert!(
7463            body.contains("at://did:plc:…/site.standard.publication/…"),
7464            "the DID form must be shown: {body}"
7465        );
7466        assert!(
7467            body.contains("at://alice.example.com/site.standard.publication/…"),
7468            "the handle form must be shown: {body}"
7469        );
7470    }
7471
7472    /// Flag on: the URL input must not be `type="url"`. A browser validates
7473    /// that type with the WHATWG URL parser, which REJECTS the DID form —
7474    /// `at://did:plc:…/…` fails as an invalid port, the same failure
7475    /// `url::Url::parse` has (see `feed::is_storable_feed_url`) — so the form
7476    /// would refuse to submit the very string the hint asks for.
7477    #[tokio::test]
7478    async fn manage_url_input_accepts_a_did_uri_when_the_flag_is_on() {
7479        let did = "did:plc:reader";
7480        let body = signed_in_body(standard_site_state(true, did).await, "/manage", did).await;
7481        let input = feed_url_input(&body);
7482        assert!(
7483            input.contains("type=\"text\""),
7484            "the input must be type=text so a DID-form at:// URI can be submitted: {input}"
7485        );
7486        assert!(
7487            input.contains("inputmode=\"url\""),
7488            "the URL keyboard is still wanted: {input}"
7489        );
7490    }
7491
7492    /// Flag on, `type="text"` drops the browser's scheme check, so a pasted
7493    /// `example.com/blog` would reach the handler and come back as "Couldn't
7494    /// find a feed" — wrong, the site likely has one. A `pattern` keeps the
7495    /// browser asking for a scheme while still admitting `at://` (both cases:
7496    /// the handler canonicalises the scheme).
7497    #[tokio::test]
7498    async fn manage_url_input_still_requires_a_scheme_when_the_flag_is_on() {
7499        let did = "did:plc:reader";
7500        let body = signed_in_body(standard_site_state(true, did).await, "/manage", did).await;
7501        let input = feed_url_input(&body);
7502        assert!(
7503            input.contains(&format!("pattern=\"{FEED_URL_PATTERN}\"")),
7504            "the text input must keep a scheme check: {input}"
7505        );
7506    }
7507
7508    /// Flag off: every `at://` paste is refused, so the form must not say
7509    /// publications are accepted — and the input keeps browser URL validation.
7510    #[tokio::test]
7511    async fn manage_does_not_advertise_publications_when_the_flag_is_off() {
7512        let did = "did:plc:reader";
7513        let state = standard_site_state(false, did).await;
7514        assert!(!state.config.standard_site);
7515        let page = signed_in_body(state, "/manage", did).await;
7516        // The `<head>` carries the site's link card, whose one-line description
7517        // names standard.site whatever the flag says — as the landing page does
7518        // with the flag off (a stored publication is polled regardless). What
7519        // must not advertise is the page: everything after `</head>`.
7520        let body = &page[page.find("</head>").expect("a <head>")..];
7521        assert!(
7522            !body.contains("site.standard.publication"),
7523            "a refused form must not be advertised: {body}"
7524        );
7525        // The shared footer links the `/standard-site` feature page on every
7526        // page, flag on or off — that page itself says the instance isn't
7527        // accepting new publication subscriptions — so the check is on the
7528        // page above the footer, where the form and its hints are.
7529        let above_footer = body
7530            .split("<footer")
7531            .next()
7532            .expect("split yields at least one piece");
7533        assert!(
7534            above_footer.contains("id=\"feed-url\""),
7535            "the form must be above the footer: {body}"
7536        );
7537        assert!(
7538            !above_footer.contains("standard.site"),
7539            "a refused form must not be advertised: {body}"
7540        );
7541        assert!(
7542            feed_url_input(body).contains("type=\"url\""),
7543            "with the flag off the input is unchanged"
7544        );
7545    }
7546
7547    /// Flag on: the landing page says publications sit beside feeds AND how to
7548    /// subscribe to one.
7549    #[tokio::test]
7550    async fn landing_describes_publications_and_how_to_subscribe_when_on() {
7551        let body = public_body(standard_site_state(true, "did:plc:x").await, "/").await;
7552        assert!(body.contains("standard.site"), "{body}");
7553        assert!(
7554            body.contains("at://did:plc:…/site.standard.publication/…"),
7555            "the landing page must show the DID form: {body}"
7556        );
7557        assert!(
7558            body.contains("at://alice.example.com/site.standard.publication/…"),
7559            "the landing page must show the handle form: {body}"
7560        );
7561    }
7562
7563    /// Flag off: the landing page still says what a publication is (a stored
7564    /// one is polled whatever the flag says), but shows no paste instructions
7565    /// and says new ones are not accepted here.
7566    #[tokio::test]
7567    async fn landing_does_not_tell_visitors_to_paste_a_publication_when_off() {
7568        let body = public_body(standard_site_state(false, "did:plc:x").await, "/").await;
7569        assert!(body.contains("standard.site"), "{body}");
7570        assert!(
7571            !body.contains("at://did:plc:…/site.standard.publication/…"),
7572            "no paste instructions with the flag off: {body}"
7573        );
7574        assert!(
7575            !body.contains("at://alice.example.com/site.standard.publication/…"),
7576            "no paste instructions with the flag off: {body}"
7577        );
7578        assert!(
7579            body.contains("isn't accepting new publication subscriptions"),
7580            "the page must say the form is closed here: {body}"
7581        );
7582    }
7583
7584    /// Flag on: /about has a publications section with both spellings.
7585    #[tokio::test]
7586    async fn about_describes_publications_and_how_to_subscribe_when_on() {
7587        let body = public_body(standard_site_state(true, "did:plc:x").await, "/about").await;
7588        assert!(body.contains("site.standard.publication"), "{body}");
7589        assert!(body.contains("site.standard.document"), "{body}");
7590        assert!(
7591            body.contains("at://did:plc:…/site.standard.publication/…"),
7592            "{body}"
7593        );
7594        assert!(
7595            body.contains("at://alice.example.com/site.standard.publication/…"),
7596            "{body}"
7597        );
7598    }
7599
7600    /// Flag off: /about keeps the description, drops the paste instructions.
7601    #[tokio::test]
7602    async fn about_does_not_tell_visitors_to_paste_a_publication_when_off() {
7603        let body = public_body(standard_site_state(false, "did:plc:x").await, "/about").await;
7604        assert!(body.contains("site.standard.publication"), "{body}");
7605        assert!(
7606            !body.contains("at://did:plc:…/site.standard.publication/…"),
7607            "no paste instructions with the flag off: {body}"
7608        );
7609        assert!(
7610            !body.contains("at://alice.example.com/site.standard.publication/…"),
7611            "no paste instructions with the flag off: {body}"
7612        );
7613        assert!(
7614            body.contains("isn't accepting new publication subscriptions"),
7615            "{body}"
7616        );
7617    }
7618
7619    // ---- the standard.site feature page (`/standard-site`) -----------------
7620    //
7621    // A public page, like `/about`: what a publication is, what is shown from
7622    // it, how to subscribe (flag-conditional, as on the other public pages),
7623    // and the honest limits. It also carries the "latest releases" call-out.
7624
7625    /// Signed out, with the default config, the page renders.
7626    #[tokio::test]
7627    async fn standard_site_page_renders_signed_out() {
7628        let body = public_body(test_state(&[]).await, "/standard-site").await;
7629        assert!(body.contains("site.standard.publication"), "{body}");
7630        assert!(body.contains("site.standard.document"), "{body}");
7631        assert!(
7632            body.contains("<title>standard.site — FeatherReader</title>"),
7633            "{body}"
7634        );
7635    }
7636
7637    /// Flag on: the page says how to subscribe, in both spellings, and that a
7638    /// handle is resolved to its DID.
7639    #[tokio::test]
7640    async fn standard_site_page_tells_how_to_subscribe_when_on() {
7641        let body = public_body(
7642            standard_site_state(true, "did:plc:x").await,
7643            "/standard-site",
7644        )
7645        .await;
7646        assert!(
7647            body.contains("at://did:plc:…/site.standard.publication/…"),
7648            "the DID form must be shown: {body}"
7649        );
7650        assert!(
7651            body.contains("at://alice.example.com/site.standard.publication/…"),
7652            "the handle form must be shown: {body}"
7653        );
7654        assert!(
7655            body.contains("resolved to its DID"),
7656            "the handle resolution must be stated: {body}"
7657        );
7658        assert!(
7659            !body.contains("isn't accepting new publication subscriptions"),
7660            "{body}"
7661        );
7662    }
7663
7664    /// Flag off: `add_subscription` refuses every `at://` paste, so the page
7665    /// must not tell visitors to paste one — it says new publication
7666    /// subscriptions are not accepted here, and that stored ones are still read.
7667    #[tokio::test]
7668    async fn standard_site_page_does_not_tell_visitors_to_paste_when_off() {
7669        let state = standard_site_state(false, "did:plc:x").await;
7670        assert!(!state.config.standard_site);
7671        let body = public_body(state, "/standard-site").await;
7672        assert!(body.contains("site.standard.publication"), "{body}");
7673        assert!(
7674            !body.contains("at://did:plc:…/site.standard.publication/…"),
7675            "no paste instructions with the flag off: {body}"
7676        );
7677        assert!(
7678            !body.contains("at://alice.example.com/site.standard.publication/…"),
7679            "no paste instructions with the flag off: {body}"
7680        );
7681        assert!(
7682            body.contains("isn't accepting new publication subscriptions"),
7683            "the page must say the form is closed here: {body}"
7684        );
7685        assert!(
7686            body.contains("already follows are still read"),
7687            "stored publications are polled whatever the flag says: {body}"
7688        );
7689    }
7690
7691    /// The releases call-out links each release's GitHub page and the
7692    /// changelog, on the feature page and on the landing page.
7693    #[tokio::test]
7694    async fn releases_callout_links_the_release_pages() {
7695        for path in ["/standard-site", "/"] {
7696            let body = public_body(test_state(&[]).await, path).await;
7697            for tag in ["v0.4.1", "v0.4.0"] {
7698                let href = format!(
7699                    "href=\"https://github.com/justin-stanley/feather-reader/releases/tag/{tag}\""
7700                );
7701                assert!(body.contains(&href), "{path} must link {tag}: {body}");
7702            }
7703            assert!(
7704                body.contains(
7705                    "https://github.com/justin-stanley/feather-reader/blob/main/CHANGELOG.md"
7706                ),
7707                "{path} must link the changelog: {body}"
7708            );
7709        }
7710    }
7711
7712    /// The feature page is reachable from the landing page, from `/about`, and
7713    /// from the shared footer (`/privacy` renders nothing but prose and that
7714    /// footer, so it stands in for every page that includes it).
7715    #[tokio::test]
7716    async fn landing_about_and_footer_link_the_standard_site_page() {
7717        for path in ["/", "/about", "/privacy"] {
7718            let body = public_body(test_state(&[]).await, path).await;
7719            assert!(
7720                body.contains("href=\"/standard-site\""),
7721                "{path} must link the feature page: {body}"
7722            );
7723        }
7724    }
7725
7726    /// `RELEASES` is the one place a release is described, so its shape is
7727    /// pinned: newest first, dates as the CHANGELOG headings spell them, and
7728    /// both derived links pointing where the template promises.
7729    #[test]
7730    fn releases_are_newest_first_and_link_the_tag_and_changelog() {
7731        assert!(!RELEASES.is_empty());
7732        let parse = |v: &str| -> Vec<u32> {
7733            v.split('.')
7734                .map(|p| p.parse::<u32>().expect("a numeric version part"))
7735                .collect()
7736        };
7737        for pair in RELEASES.windows(2) {
7738            assert!(
7739                parse(pair[0].version) > parse(pair[1].version),
7740                "{} must come before {}",
7741                pair[0].version,
7742                pair[1].version
7743            );
7744        }
7745        for r in RELEASES {
7746            assert_eq!(parse(r.version).len(), 3, "{}", r.version);
7747            assert!(
7748                chrono::NaiveDate::parse_from_str(r.date, "%Y-%m-%d").is_ok(),
7749                "{} is not YYYY-MM-DD",
7750                r.date
7751            );
7752            assert!(!r.summary.trim().is_empty());
7753            assert!(!r.summary.contains('<'), "the summary is plain text");
7754            assert_eq!(
7755                r.url(),
7756                format!(
7757                    "https://github.com/justin-stanley/feather-reader/releases/tag/v{}",
7758                    r.version
7759                )
7760            );
7761        }
7762        // The newest entry is this build's own version, so a release cannot
7763        // ship without adding itself to the call-out.
7764        let latest = &RELEASES[0];
7765        assert_eq!(latest.version, env!("CARGO_PKG_VERSION"));
7766        assert_eq!(
7767            latest.changelog_url(),
7768            "https://github.com/justin-stanley/feather-reader/blob/main/CHANGELOG.md#044--2026-10-05"
7769        );
7770    }
7771
7772    /// Public and static like `/about`, so it is cacheable on the same terms.
7773    #[tokio::test]
7774    async fn standard_site_page_is_publicly_cacheable() {
7775        let resp = router(test_state(&[]).await)
7776            .oneshot(
7777                Request::builder()
7778                    .uri("/standard-site")
7779                    .body(Body::empty())
7780                    .unwrap(),
7781            )
7782            .await
7783            .unwrap();
7784        assert_eq!(resp.status(), StatusCode::OK);
7785        assert_eq!(
7786            resp.headers().get(header::CACHE_CONTROL).unwrap(),
7787            "public, max-age=300"
7788        );
7789    }
7790
7791    #[tokio::test]
7792    async fn cache_control_public_on_about_no_store_on_authed() {
7793        let state = test_state(&["did:plc:admin"]).await;
7794        let admin_cookie = session_cookie(&state, "did:plc:admin", None);
7795        let app = router(state);
7796
7797        // /about → public, cacheable.
7798        let about = app
7799            .clone()
7800            .oneshot(
7801                Request::builder()
7802                    .uri("/about")
7803                    .body(Body::empty())
7804                    .unwrap(),
7805            )
7806            .await
7807            .unwrap();
7808        assert_eq!(
7809            about.headers().get(header::CACHE_CONTROL).unwrap(),
7810            "public, max-age=300"
7811        );
7812        // The security headers are still intact.
7813        // The VALUE, spelled out here rather than compared to the constant —
7814        // `== CONTENT_SECURITY_POLICY` passes with the constant gutted. This
7815        // used to assert only that the header existed, which a policy of
7816        // `default-src *` satisfies.
7817        assert_eq!(
7818            about.headers()["content-security-policy"],
7819            EXPECTED_CSP,
7820            "the CSP is not the policy the router promises"
7821        );
7822        assert_eq!(about.headers().get("x-frame-options").unwrap(), "DENY");
7823
7824        // /privacy and /terms are static public pages → public, cacheable.
7825        for path in ["/privacy", "/terms"] {
7826            let resp = app
7827                .clone()
7828                .oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
7829                .await
7830                .unwrap();
7831            assert_eq!(resp.status(), StatusCode::OK);
7832            assert_eq!(
7833                resp.headers().get(header::CACHE_CONTROL).unwrap(),
7834                "public, max-age=300",
7835                "{path} should be publicly cacheable"
7836            );
7837            // Security headers apply to these pages too.
7838            assert_eq!(resp.headers()["content-security-policy"], EXPECTED_CSP);
7839            assert_eq!(resp.headers().get("x-frame-options").unwrap(), "DENY");
7840        }
7841
7842        // The bare /login landing → public, cacheable.
7843        let login = app
7844            .clone()
7845            .oneshot(
7846                Request::builder()
7847                    .uri("/login")
7848                    .body(Body::empty())
7849                    .unwrap(),
7850            )
7851            .await
7852            .unwrap();
7853        assert_eq!(
7854            login.headers().get(header::CACHE_CONTROL).unwrap(),
7855            "public, max-age=300"
7856        );
7857
7858        // An authenticated page → no-store.
7859        let home = app
7860            .oneshot(
7861                Request::builder()
7862                    .uri("/")
7863                    .header(header::COOKIE, admin_cookie)
7864                    .body(Body::empty())
7865                    .unwrap(),
7866            )
7867            .await
7868            .unwrap();
7869        assert_eq!(
7870            home.headers().get(header::CACHE_CONTROL).unwrap(),
7871            "no-store"
7872        );
7873    }
7874
7875    // -- link cards (Open Graph) -----------------------------------------------
7876    //
7877    // Bluesky's card service fetches the HTML server-side, runs no JS, and
7878    // resolves nothing relative. Measured before these tags existed:
7879    // `cardyb.bsky.app/v1/extract?url=https://feather-reader.com/` returned
7880    // `{"title":"FeatherReader — read, quietly","description":"","image":""}`.
7881
7882    /// Everything up to `</head>` — the only part a card fetcher reads.
7883    fn head(body: &str) -> &str {
7884        let end = body.find("</head>").expect("a <head>");
7885        &body[..end]
7886    }
7887
7888    /// The `content` of the first `<meta …>` tag carrying `attr` (e.g.
7889    /// `property="og:title"`), or `None` when no tag carries it.
7890    fn meta(head: &str, attr: &str) -> Option<String> {
7891        let tag_start = head.find(attr)?;
7892        let rest = &head[tag_start..];
7893        let tag_end = rest.find('>')?;
7894        let tag = &rest[..tag_end];
7895        let content = tag.find("content=\"")? + "content=\"".len();
7896        let close = tag[content..].find('"')?;
7897        Some(tag[content..content + close].to_string())
7898    }
7899
7900    /// A state whose public origin is production's. The card URLs must be
7901    /// absolute on THAT origin: a relative `/static/…` is what the card
7902    /// fetcher cannot use.
7903    async fn production_origin_state() -> AppState {
7904        let db = store::init_url("sqlite::memory:").await.unwrap();
7905        store::ensure_seed(&db, &["did:plc:admin".to_string()])
7906            .await
7907            .unwrap();
7908        let config = Config {
7909            allowed_dids: vec!["did:plc:admin".to_string()],
7910            cookie_secret: "test-cookie-secret-000".to_string(),
7911            beta_cap: 3,
7912            public_url: "https://feather-reader.com".to_string(),
7913            ..Config::default()
7914        };
7915        AppState::new(config, db).unwrap()
7916    }
7917
7918    /// The landing page and /about each carry a complete card with absolute
7919    /// https URLs, and the two describe different things.
7920    #[tokio::test]
7921    async fn landing_and_about_render_open_graph_cards_with_absolute_urls() {
7922        let landing = public_body(production_origin_state().await, "/").await;
7923        let about = public_body(production_origin_state().await, "/about").await;
7924        let (lh, ah) = (head(&landing), head(&about));
7925
7926        assert_eq!(
7927            meta(lh, "property=\"og:title\"").as_deref(),
7928            Some("FeatherReader — read, quietly"),
7929            "{lh}"
7930        );
7931        assert_eq!(
7932            meta(ah, "property=\"og:title\"").as_deref(),
7933            Some("About — FeatherReader"),
7934            "{ah}"
7935        );
7936        for (h, path) in [(lh, "/"), (ah, "/about")] {
7937            let url = format!("https://feather-reader.com{path}");
7938            assert_eq!(
7939                meta(h, "property=\"og:url\"").as_deref(),
7940                Some(url.as_str())
7941            );
7942            assert!(
7943                h.contains(&format!("<link rel=\"canonical\" href=\"{url}\"")),
7944                "{path} must carry a canonical link: {h}"
7945            );
7946            let image = meta(h, "property=\"og:image\"").unwrap_or_default();
7947            assert!(
7948                image.starts_with("https://feather-reader.com/static/"),
7949                "{path}: og:image must be absolute on the public origin, got {image:?}"
7950            );
7951            assert_eq!(
7952                meta(h, "name=\"twitter:card\"").as_deref(),
7953                Some("summary_large_image")
7954            );
7955            assert_eq!(meta(h, "property=\"og:type\"").as_deref(), Some("website"));
7956            assert_eq!(
7957                meta(h, "property=\"og:site_name\"").as_deref(),
7958                Some("FeatherReader")
7959            );
7960            let description = meta(h, "property=\"og:description\"").unwrap_or_default();
7961            assert!(!description.is_empty(), "{path}: og:description is empty");
7962            assert_eq!(
7963                meta(h, "name=\"description\"").as_deref(),
7964                Some(description.as_str()),
7965                "{path}: the meta description and og:description must agree"
7966            );
7967        }
7968        assert_ne!(
7969            meta(lh, "property=\"og:description\""),
7970            meta(ah, "property=\"og:description\""),
7971            "the landing page and /about must not share a description"
7972        );
7973    }
7974
7975    /// The origin comes from `FEATHERREADER_PUBLIC_URL`, not a constant.
7976    #[tokio::test]
7977    async fn card_urls_follow_the_configured_public_url() {
7978        let db = store::init_url("sqlite::memory:").await.unwrap();
7979        store::ensure_seed(&db, &[]).await.unwrap();
7980        let config = Config {
7981            cookie_secret: "test-cookie-secret-000".to_string(),
7982            public_url: "https://reader.example.org".to_string(),
7983            ..Config::default()
7984        };
7985        let body = public_body(AppState::new(config, db).unwrap(), "/privacy").await;
7986        let h = head(&body);
7987        assert_eq!(
7988            meta(h, "property=\"og:url\"").as_deref(),
7989            Some("https://reader.example.org/privacy")
7990        );
7991        assert_eq!(
7992            meta(h, "property=\"og:image\"").as_deref(),
7993            Some("https://reader.example.org/static/social-card.png")
7994        );
7995    }
7996
7997    /// Every signed-out page describes itself: no two share a description,
7998    /// and each `og:url` is its own path.
7999    #[tokio::test]
8000    async fn public_pages_each_carry_their_own_description() {
8001        let paths = [
8002            "/",
8003            "/about",
8004            "/privacy",
8005            "/terms",
8006            "/stats",
8007            "/standard-site",
8008            "/login",
8009            "/beta/redeem",
8010        ];
8011        let mut seen = std::collections::HashSet::new();
8012        for path in paths {
8013            let body = public_body(production_origin_state().await, path).await;
8014            let h = head(&body);
8015            let description = meta(h, "name=\"description\"").unwrap_or_default();
8016            assert!(!description.is_empty(), "{path} has no description: {h}");
8017            assert!(
8018                seen.insert(description.clone()),
8019                "{path} repeats another page's description: {description:?}"
8020            );
8021            assert_eq!(
8022                meta(h, "property=\"og:url\"").as_deref(),
8023                Some(format!("https://feather-reader.com{path}").as_str()),
8024                "{path}"
8025            );
8026            assert!(
8027                !h.contains("name=\"robots\""),
8028                "{path} is public and must not be noindex: {h}"
8029            );
8030        }
8031    }
8032
8033    /// The share image is served from `/static` as a PNG of the dimensions the
8034    /// tags promise, well under the 1 MB card fetchers tolerate, and cacheable.
8035    #[tokio::test]
8036    async fn share_image_is_served_as_a_png_of_the_advertised_size() {
8037        let landing = public_body(production_origin_state().await, "/").await;
8038        let h = head(&landing);
8039        let image = meta(h, "property=\"og:image\"").unwrap();
8040        let path = image.strip_prefix("https://feather-reader.com").unwrap();
8041        let width: u32 = meta(h, "property=\"og:image:width\"")
8042            .unwrap()
8043            .parse()
8044            .unwrap();
8045        let height: u32 = meta(h, "property=\"og:image:height\"")
8046            .unwrap()
8047            .parse()
8048            .unwrap();
8049        assert_eq!((width, height), (1200, 630), "Bluesky renders ~1.91:1");
8050        assert_eq!(
8051            meta(h, "property=\"og:image:type\"").as_deref(),
8052            Some("image/png")
8053        );
8054        assert!(
8055            !meta(h, "property=\"og:image:alt\"")
8056                .unwrap_or_default()
8057                .is_empty(),
8058            "the image needs alt text"
8059        );
8060
8061        let resp = router(production_origin_state().await)
8062            .oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
8063            .await
8064            .unwrap();
8065        assert_eq!(resp.status(), StatusCode::OK, "{path}");
8066        assert_eq!(resp.headers()[header::CONTENT_TYPE], "image/png");
8067        assert_eq!(resp.headers()[header::CACHE_CONTROL], "public, max-age=300");
8068        let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
8069            .await
8070            .expect("the image is under 1 MB");
8071        assert_eq!(&bytes[..8], b"\x89PNG\r\n\x1a\n", "not a PNG");
8072        // IHDR: width and height, big-endian, at offsets 16 and 20.
8073        let be = |at: usize| u32::from_be_bytes(bytes[at..at + 4].try_into().unwrap());
8074        assert_eq!(
8075            (be(16), be(20)),
8076            (width, height),
8077            "the PNG's own dimensions must match the tags"
8078        );
8079    }
8080
8081    /// A page that renders a session's private view carries the site's generic
8082    /// card — nothing from the view reaches `<head>` — and is `noindex`.
8083    #[tokio::test]
8084    async fn private_pages_keep_user_data_out_of_the_card() {
8085        for path in ["/", "/manage"] {
8086            let state = production_origin_state().await;
8087            let body = signed_in_body(state, path, "did:plc:admin").await;
8088            let h = head(&body);
8089            assert!(
8090                h.contains("<meta name=\"robots\" content=\"noindex\""),
8091                "{path}: a private view must be noindex: {h}"
8092            );
8093            assert_eq!(
8094                meta(h, "property=\"og:title\"").as_deref(),
8095                Some("FeatherReader — read, quietly"),
8096                "{path}: the card of a private view is the site's generic one"
8097            );
8098            assert_eq!(
8099                meta(h, "property=\"og:url\"").as_deref(),
8100                Some("https://feather-reader.com/"),
8101                "{path}: og:url of a private view is the front door, not the private path"
8102            );
8103            for private in ["reader.example", "did:plc:admin"] {
8104                assert!(
8105                    !h.contains(private),
8106                    "{path}: {private:?} must not reach <head>: {h}"
8107                );
8108            }
8109        }
8110    }
8111
8112    #[tokio::test]
8113    async fn beta_redeem_page_renders() {
8114        let state = test_state(&[]).await;
8115        let app = router(state);
8116        let resp = app
8117            .oneshot(
8118                Request::builder()
8119                    .uri("/beta/redeem")
8120                    .body(Body::empty())
8121                    .unwrap(),
8122            )
8123            .await
8124            .unwrap();
8125        assert_eq!(resp.status(), StatusCode::OK);
8126        let bytes = axum::body::to_bytes(resp.into_body(), 256 * 1024)
8127            .await
8128            .unwrap();
8129        let html = String::from_utf8(bytes.to_vec()).unwrap();
8130        assert!(html.contains("Invite code"));
8131        assert!(html.contains("/beta/redeem"));
8132    }
8133
8134    #[tokio::test]
8135    async fn rate_limit_returns_429_after_burst() {
8136        // Configure a trusted proxy header so the limiter keys on the forwarded
8137        // IP (the oneshot harness sets no ConnectInfo socket peer).
8138        let db = store::init_url("sqlite::memory:").await.unwrap();
8139        store::ensure_seed(&db, &[]).await.unwrap();
8140        let config = Config {
8141            cookie_secret: "test-cookie-secret-000".to_string(),
8142            beta_cap: 3,
8143            trusted_ip_header: Some("cf-connecting-ip".to_string()),
8144            ..Config::default()
8145        };
8146        let state = AppState::new(config, db).unwrap();
8147        let app = router(state);
8148        // Hammer POST /beta/redeem past the burst from a single (trusted) IP. The
8149        // handler itself returns 200 (re-render) on a bad code; the limiter is
8150        // what eventually yields 429.
8151        let mut saw_429 = false;
8152        for _ in 0..(RATE_BURST as usize + 5) {
8153            let resp = app
8154                .clone()
8155                .oneshot(
8156                    Request::builder()
8157                        .method("POST")
8158                        .uri("/beta/redeem")
8159                        .header("content-type", "application/x-www-form-urlencoded")
8160                        .header("cf-connecting-ip", "203.0.113.200")
8161                        .body(Body::from("code=FEATHER-NOPENOPE"))
8162                        .unwrap(),
8163                )
8164                .await
8165                .unwrap();
8166            if resp.status() == StatusCode::TOO_MANY_REQUESTS {
8167                saw_429 = true;
8168                break;
8169            }
8170        }
8171        assert!(saw_429, "expected a 429 after exhausting the burst");
8172    }
8173
8174    /// **A forged `X-Forwarded-For` does not key the limiter** — the property
8175    /// the middleware's comment cites this test as proof of.
8176    ///
8177    /// The previous version rotated the forged header and asserted that no
8178    /// request was EVER 429'd. Rotating addresses can never exhaust a per-IP
8179    /// burst, so that assertion held whether the header was trusted or
8180    /// ignored — it passed in the vulnerable configuration too. And with no
8181    /// socket peer the limiter fails open, so nothing could have been keyed on
8182    /// anything. Now one real peer sends `RATE_BURST + 5` requests, each with
8183    /// a DIFFERENT forged header, and the last must be 429: they all landed in
8184    /// the peer's bucket. A limiter keying on the header mints a fresh bucket
8185    /// per request and never trips — which is exactly what the mutation does.
8186    #[tokio::test]
8187    async fn a_forged_forwarded_for_header_does_not_key_the_limiter() {
8188        let state = test_state(&[]).await;
8189        assert!(
8190            state.config.trusted_ip_header.is_none(),
8191            "no proxy header is trusted here"
8192        );
8193        let app = router(state);
8194        let peer = std::net::SocketAddr::from(([203, 0, 113, 7], 40000));
8195        let mut saw_429 = false;
8196        for i in 0..(RATE_BURST as usize + 5) {
8197            let forged = format!("10.9.8.{}", i % 250);
8198            let resp = app
8199                .clone()
8200                .oneshot(
8201                    Request::builder()
8202                        .method("POST")
8203                        .uri("/beta/redeem")
8204                        .header("content-type", "application/x-www-form-urlencoded")
8205                        .header("x-forwarded-for", forged)
8206                        .extension(axum::extract::ConnectInfo(peer))
8207                        .body(Body::from("code=FEATHER-NOPENOPE"))
8208                        .unwrap(),
8209                )
8210                .await
8211                .unwrap();
8212            if resp.status() == StatusCode::TOO_MANY_REQUESTS {
8213                saw_429 = true;
8214                break;
8215            }
8216        }
8217        assert!(
8218            saw_429,
8219            "rotating a forged X-Forwarded-For minted fresh buckets: the limiter is keyed on an attacker-chosen header"
8220        );
8221    }
8222
8223    // -- OPML import body cap (DefaultBodyLimit → 413) -------------------------
8224
8225    /// **A private feed is refused BEFORE it is fetched.** The add path's
8226    /// privacy gate had no test at all — `private_feeds_are_classified_private_
8227    /// across_providers` says "the add + OPML paths both gate on this
8228    /// classifier" and nothing checked either. The gate exists so a
8229    /// token-bearing URL never reaches the network; the assertion that
8230    /// matters is the server's hit count: zero.
8231    #[tokio::test]
8232    async fn subscribing_to_a_private_feed_never_reaches_the_network() {
8233        let did = "did:plc:privateadder";
8234        let state = test_state_with_caps(did, 0, 0).await;
8235        let (base, hits) = crate::net::tests::serve_body_counted(b"<rss/>".to_vec()).await;
8236        let port: u16 = base
8237            .trim_end_matches('/')
8238            .rsplit(':')
8239            .next()
8240            .unwrap()
8241            .parse()
8242            .unwrap();
8243        crate::net::test_host_override(
8244            "private-add.test",
8245            std::net::SocketAddr::from(([127, 0, 0, 1], port)),
8246        );
8247        let cookie = session_cookie(&state, did, None);
8248        let resp = router(state.clone())
8249            .oneshot(
8250                Request::builder()
8251                    .method("POST")
8252                    .uri("/subscriptions")
8253                    .header(header::COOKIE, cookie)
8254                    .header("content-type", "application/x-www-form-urlencoded")
8255                    .body(Body::from(format!(
8256                        "url=http%3A%2F%2Fprivate-add.test%3A{port}%2Ffeed%2Fprivate%2Fdeadbeefcafe1234"
8257                    )))
8258                    .unwrap(),
8259            )
8260            .await
8261            .unwrap();
8262        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8263        let loc = resp
8264            .headers()
8265            .get(header::LOCATION)
8266            .unwrap()
8267            .to_str()
8268            .unwrap();
8269        assert!(loc.contains("Private"), "not refused as private: {loc}");
8270        assert_eq!(
8271            hits.load(std::sync::atomic::Ordering::SeqCst),
8272            0,
8273            "the private feed was FETCHED before being refused"
8274        );
8275        assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
8276    }
8277
8278    /// **OPML import skips a private feed without storing or publishing it.**
8279    /// The import path does not fetch, so "never fetched" is not the signal
8280    /// here; "never stored, never written to the PDS" is. The batch write's
8281    /// bytes are captured and must not carry the URL.
8282    #[tokio::test]
8283    async fn opml_import_skips_a_private_feed_without_storing_or_publishing_it() {
8284        let did = "did:plc:renamer4";
8285        let (sidecar, bodies) = spawn_logging_sidecar().await;
8286        let state = test_state_with_sidecar(&[did], &sidecar).await;
8287        let tokened = "https://www.patreon.com/rss/author?auth=Zm9vYmFyc2VjcmV0dG9rZW4";
8288        let opml = format!(
8289            "<?xml version=\"1.0\"?>\n<opml version=\"2.0\"><head><title>t</title></head><body>\n\
8290             <outline type=\"rss\" text=\"Public\" xmlUrl=\"https://public.example/feed.xml\"/>\n\
8291             <outline type=\"rss\" text=\"Paid\" xmlUrl=\"{tokened}\"/>\n\
8292             </body></opml>"
8293        );
8294        let (ct, body) = opml_multipart(opml.as_bytes());
8295        let cookie = session_cookie(&state, did, None);
8296        let resp = router(state.clone())
8297            .oneshot(
8298                Request::builder()
8299                    .method("POST")
8300                    .uri("/opml")
8301                    .header(header::COOKIE, cookie)
8302                    .header("content-type", ct)
8303                    .body(Body::from(body))
8304                    .unwrap(),
8305            )
8306            .await
8307            .unwrap();
8308        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8309        let loc = resp
8310            .headers()
8311            .get(header::LOCATION)
8312            .unwrap()
8313            .to_str()
8314            .unwrap();
8315        assert!(
8316            loc.contains("skipped%20as%20private"),
8317            "not reported as skipped: {loc}"
8318        );
8319        assert!(store::get_feed_by_url(&state.db, tokened)
8320            .await
8321            .unwrap()
8322            .is_none());
8323        let sent = bodies.lock().unwrap().join("\n");
8324        assert!(
8325            sent.contains("public.example"),
8326            "the public feed was not written: {sent}"
8327        );
8328        assert!(
8329            !sent.contains("Zm9vYmFyc2VjcmV0dG9rZW4"),
8330            "the secret was PUBLISHED to the PDS: {sent}"
8331        );
8332    }
8333
8334    /// **`GET /login?handle=` is gated like `POST /login`.** Only the POST was
8335    /// tested; the GET form starts the same handshake and had no test, so
8336    /// deleting its gate left the suite green.
8337    #[tokio::test]
8338    async fn get_login_without_a_seat_is_refused() {
8339        let state = test_state(&[]).await;
8340        let resp = router(state)
8341            .oneshot(
8342                Request::builder()
8343                    .method("GET")
8344                    .uri("/login?handle=alice.bsky.social")
8345                    .body(Body::empty())
8346                    .unwrap(),
8347            )
8348            .await
8349            .unwrap();
8350        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8351        assert_eq!(
8352            resp.headers().get(header::LOCATION).unwrap(),
8353            "/beta/redeem"
8354        );
8355    }
8356
8357    /// A sidecar fake that answers every request `ok` and records the PATH of
8358    /// each in arrival order, plus every body — for asserting what was sent,
8359    /// and in what order.
8360    async fn spawn_logging_sidecar() -> (String, std::sync::Arc<std::sync::Mutex<Vec<String>>>) {
8361        use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
8362        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8363        let addr = listener.local_addr().unwrap();
8364        let log = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
8365        let sink = log.clone();
8366        tokio::spawn(async move {
8367            loop {
8368                let Ok((mut sock, _)) = listener.accept().await else {
8369                    break;
8370                };
8371                let mut raw: Vec<u8> = Vec::new();
8372                let mut chunk = [0u8; 4096];
8373                let text = loop {
8374                    let Ok(n) = sock.read(&mut chunk).await else {
8375                        break String::new();
8376                    };
8377                    if n == 0 {
8378                        break String::from_utf8_lossy(&raw).to_string();
8379                    }
8380                    raw.extend_from_slice(&chunk[..n]);
8381                    let Some(split) = raw.windows(4).position(|w| w == b"\r\n\r\n") else {
8382                        continue;
8383                    };
8384                    let (head, body) = raw.split_at(split + 4);
8385                    let want = String::from_utf8_lossy(head).lines().find_map(|l| {
8386                        let (k, v) = l.split_once(':')?;
8387                        k.eq_ignore_ascii_case("content-length")
8388                            .then(|| v.trim().parse::<usize>().ok())?
8389                    });
8390                    if want.is_none_or(|w| body.len() >= w) {
8391                        break String::from_utf8_lossy(&raw).to_string();
8392                    }
8393                };
8394                let path = text
8395                    .lines()
8396                    .next()
8397                    .and_then(|l| l.split_whitespace().nth(1))
8398                    .unwrap_or("")
8399                    .to_string();
8400                let body_text = text
8401                    .split_once("\r\n\r\n")
8402                    .map(|(_, b)| b)
8403                    .unwrap_or("")
8404                    .to_string();
8405                sink.lock().unwrap().push(format!("{path} {body_text}"));
8406                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();
8407                let resp = format!(
8408                    "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
8409                    body.len(),
8410                    body
8411                );
8412                let _ = sock.write_all(resp.as_bytes()).await;
8413                let _ = sock.flush().await;
8414            }
8415        });
8416        (format!("http://{addr}"), log)
8417    }
8418
8419    /// **The sign-out flush settles a split flush's landed prefix too.** It is
8420    /// `readstate::flush_did` under a timeout, so it shares the fix — pinned
8421    /// here so a sign-out path that grew its own flush would not silently lose
8422    /// it. Call 1 of 2 lands, call 2's connection drops: the landed cursors are
8423    /// created and clean, the rest stay dirty to park until the next sign-in.
8424    #[tokio::test]
8425    async fn the_sign_out_flush_settles_what_a_split_flush_landed() {
8426        use crate::readstate::tests as rs;
8427        for backend in [
8428            crate::metrics::Backend::Sidecar,
8429            crate::metrics::Backend::Rust,
8430        ] {
8431            let fake = std::sync::Arc::new(std::sync::Mutex::new(rs::FakeRepo::default()));
8432            let state = rs::state_on(backend, &fake).await;
8433            for i in 0..250 {
8434                rs::mark_read(&state, i, "1").await;
8435            }
8436            fake.lock().unwrap().drop_call = Some(2);
8437
8438            flush_before_revoke(&state, rs::DID).await;
8439
8440            let order = rs::send_order(250);
8441            let (landed, rest) = order.split_at(crate::atproto::APPLY_WRITES_MAX_OPS);
8442            for &i in landed {
8443                let c = rs::cursor(&state, i).await;
8444                assert!(c.pds_created && !c.dirty, "{backend:?}: feed {i}");
8445            }
8446            for &i in rest {
8447                let c = rs::cursor(&state, i).await;
8448                assert!(c.dirty && !c.pds_created, "{backend:?}: feed {i}");
8449            }
8450            assert_eq!(fake.lock().unwrap().apply_calls, 2, "{backend:?}");
8451        }
8452    }
8453
8454    /// **Sign-out flushes dirty read-state BEFORE it revokes — through the
8455    /// route.** The previous version of this test called
8456    /// `flush_before_revoke` and `revoke_everywhere` itself and asserted one
8457    /// flush attempt; its doc claimed deleting the call from the handler
8458    /// "drops that to zero", which was false — the handler was never run.
8459    /// Deleting the call left the suite green: #117 regressing in full, with
8460    /// the test named after it still passing. Now `POST /logout` is driven and
8461    /// the sidecar's log must show a repo write BEFORE the revoke.
8462    #[tokio::test]
8463    async fn signing_out_flushes_before_it_revokes_through_the_route() {
8464        let did = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
8465        let (sidecar, log) = spawn_logging_sidecar().await;
8466        let state = test_state_with_sidecar(&[did], &sidecar).await;
8467        crate::store::upsert_cursor(
8468            &state.db,
8469            &crate::store::ReadCursor {
8470                did: did.to_string(),
8471                feed_url: "https://example.com/feed.xml".into(),
8472                read_through: None,
8473                read_ids: "[\"1\"]".into(),
8474                unread_ids: "[]".into(),
8475                dirty: true,
8476                pds_created: false,
8477                updated_at: "2026-09-13T21:22:40Z".into(),
8478            },
8479        )
8480        .await
8481        .unwrap();
8482        let cookie = session_cookie(&state, did, None);
8483        let resp = router(state.clone())
8484            .oneshot(
8485                Request::builder()
8486                    .method("POST")
8487                    .uri("/logout")
8488                    .header(header::COOKIE, cookie)
8489                    .body(Body::empty())
8490                    .unwrap(),
8491            )
8492            .await
8493            .unwrap();
8494        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8495
8496        let entries = log.lock().unwrap().clone();
8497        let flush = entries
8498            .iter()
8499            .position(|e| e.starts_with("/internal/repo "));
8500        let revoke = entries
8501            .iter()
8502            .position(|e| e.starts_with("/internal/revoke "));
8503        assert!(revoke.is_some(), "sign-out did not revoke: {entries:?}");
8504        assert!(
8505            flush.is_some(),
8506            "sign-out did not attempt a flush before revoking: {entries:?}"
8507        );
8508        assert!(
8509            flush < revoke,
8510            "the flush arrived AFTER the revoke — no session left to send it with: {entries:?}"
8511        );
8512    }
8513
8514    /// The policy, as a literal: the backstop the router calls "neutralises any
8515    /// XSS that slips past sanitization". `script-src 'self'` and no
8516    /// `'unsafe-inline'` on it are the two clauses that make it one.
8517    const EXPECTED_CSP: &str = "default-src 'self'; \
8518     script-src 'self'; \
8519     style-src 'self' 'unsafe-inline'; \
8520     img-src 'self' https: data:; \
8521     font-src 'self'; \
8522     connect-src 'self'; \
8523     form-action 'self'; \
8524     base-uri 'self'; \
8525     frame-ancestors 'none'; \
8526     object-src 'none'";
8527
8528    /// Build a `multipart/form-data` body carrying a single `file` field whose
8529    /// contents are `payload`, returning `(content_type, body_bytes)`.
8530    fn opml_multipart(payload: &[u8]) -> (String, Vec<u8>) {
8531        let boundary = "----featherreadertestboundary";
8532        let mut body = Vec::new();
8533        body.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
8534        body.extend_from_slice(
8535            b"Content-Disposition: form-data; name=\"file\"; filename=\"feeds.opml\"\r\n",
8536        );
8537        body.extend_from_slice(b"Content-Type: text/x-opml\r\n\r\n");
8538        body.extend_from_slice(payload);
8539        body.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes());
8540        (format!("multipart/form-data; boundary={boundary}"), body)
8541    }
8542
8543    #[tokio::test]
8544    async fn opml_import_oversize_upload_returns_413() {
8545        let state = test_state(&["did:plc:admin"]).await;
8546        let cookie = session_cookie(&state, "did:plc:admin", None);
8547        let app = router(state);
8548
8549        // A payload comfortably above the route cap.
8550        let payload = vec![b'a'; OPML_BODY_LIMIT + 1024];
8551        let (content_type, body) = opml_multipart(&payload);
8552
8553        let resp = app
8554            .oneshot(
8555                Request::builder()
8556                    .method("POST")
8557                    .uri("/opml")
8558                    .header("content-type", content_type)
8559                    .header(header::COOKIE, cookie)
8560                    .body(Body::from(body))
8561                    .unwrap(),
8562            )
8563            .await
8564            .unwrap();
8565        assert_eq!(
8566            resp.status(),
8567            StatusCode::PAYLOAD_TOO_LARGE,
8568            "an over-cap OPML upload must be rejected with 413, not collapsed to 500"
8569        );
8570    }
8571
8572    /// **The route's own cap is what refuses this, not the framework's.**
8573    ///
8574    /// `OPML_BODY_LIMIT` used to equal axum's `DefaultBodyLimit` (2 MiB), so
8575    /// the route's layer was a no-op — deleting it left every test green, and
8576    /// `opml_import_oversize_upload_returns_413` was really testing axum. The
8577    /// limit is 1 MiB now, strictly tighter, and this uploads a payload that
8578    /// sits BETWEEN the two: over ours, under the framework's. Only the
8579    /// route's layer can refuse it — remove the layer and this payload is
8580    /// accepted, which is also what demonstrates the framework's default is
8581    /// the larger of the two.
8582    #[tokio::test]
8583    async fn opml_import_over_the_route_cap_is_refused_below_the_framework_default() {
8584        let state = test_state(&["did:plc:admin"]).await;
8585        let cookie = session_cookie(&state, "did:plc:admin", None);
8586        let app = router(state);
8587
8588        // Between the two ceilings: the framework would accept this.
8589        let payload = vec![b'a'; (OPML_BODY_LIMIT + AXUM_DEFAULT_BODY_LIMIT) / 2];
8590        let (content_type, body) = opml_multipart(&payload);
8591
8592        let resp = app
8593            .oneshot(
8594                Request::builder()
8595                    .method("POST")
8596                    .uri("/opml")
8597                    .header("content-type", content_type)
8598                    .header(header::COOKIE, cookie)
8599                    .body(Body::from(body))
8600                    .unwrap(),
8601            )
8602            .await
8603            .unwrap();
8604        assert_eq!(
8605            resp.status(),
8606            StatusCode::PAYLOAD_TOO_LARGE,
8607            "a payload over the route's cap but under the framework's was accepted — \
8608             the route's own DefaultBodyLimit layer is not doing anything"
8609        );
8610    }
8611
8612    #[tokio::test]
8613    async fn opml_import_under_limit_upload_is_accepted() {
8614        let state = test_state(&["did:plc:admin"]).await;
8615        let cookie = session_cookie(&state, "did:plc:admin", None);
8616        let db = state.db.clone();
8617        let app = router(state);
8618
8619        // A small, valid OPML well under the cap: must be accepted (the handler
8620        // redirects to `/` or a flash), i.e. never 413.
8621        let opml = br#"<?xml version="1.0"?>
8622<opml version="2.0"><body>
8623  <outline text="Example" type="rss" xmlUrl="https://example.com/feed.xml"/>
8624</body></opml>"#;
8625        let (content_type, body) = opml_multipart(opml);
8626
8627        let resp = app
8628            .oneshot(
8629                Request::builder()
8630                    .method("POST")
8631                    .uri("/opml")
8632                    .header("content-type", content_type)
8633                    .header(header::COOKIE, cookie)
8634                    .body(Body::from(body))
8635                    .unwrap(),
8636            )
8637            .await
8638            .unwrap();
8639        // **Assert it was ACCEPTED, not merely that it was not a 413.**
8640        //
8641        // The old assertion was `assert_ne!(status, PAYLOAD_TOO_LARGE)`, which a
8642        // 500 satisfies — so making `import_opml` fail unconditionally left this
8643        // green. Three other OPML tests caught that mutation; the one whose name
8644        // promises to cover the under-cap case did not.
8645        assert_eq!(
8646            resp.status(),
8647            StatusCode::SEE_OTHER,
8648            "an under-cap OPML upload was not accepted (status {})",
8649            resp.status(),
8650        );
8651        // **303 alone is not acceptance.** `import_opml` redirects on several
8652        // FAILURES too — unparseable OPML, zero feeds found, every feed trimmed
8653        // by a cap — so an import that stored nothing satisfied the status check.
8654        let stored: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM feeds WHERE url = ?1")
8655            .bind("https://example.com/feed.xml")
8656            .fetch_one(&db)
8657            .await
8658            .unwrap();
8659        assert_eq!(stored, 1, "the upload was redirected but imported nothing");
8660        let location = resp
8661            .headers()
8662            .get(header::LOCATION)
8663            .and_then(|v| v.to_str().ok())
8664            .unwrap_or_default()
8665            .to_string();
8666        assert!(
8667            !location.starts_with("/login"),
8668            "the import bounced to login instead of being accepted: {location}",
8669        );
8670    }
8671
8672    #[tokio::test]
8673    async fn opml_import_logged_out_redirects_to_login() {
8674        // Logged-out callers are redirected before the body is consumed; assert
8675        // the auth short-circuit rather than a body-cap rejection.
8676        let state = test_state(&["did:plc:admin"]).await;
8677        let app = router(state);
8678
8679        let opml = b"<opml version=\"2.0\"><body></body></opml>";
8680        let (content_type, body) = opml_multipart(opml);
8681
8682        let resp = app
8683            .oneshot(
8684                Request::builder()
8685                    .method("POST")
8686                    .uri("/opml")
8687                    .header("content-type", content_type)
8688                    .body(Body::from(body))
8689                    .unwrap(),
8690            )
8691            .await
8692            .unwrap();
8693        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8694        assert_eq!(resp.headers().get(header::LOCATION).unwrap(), "/login");
8695    }
8696
8697    // -- delete-my-data (POST /account/delete) --------------------------------
8698
8699    /// A one-shot mock sidecar: binds a loopback port, answers exactly one
8700    /// `POST /internal/revoke` with `{ok:true,…}`, and reports (via the returned
8701    /// channel) the DID it was asked to revoke. Enough to prove the delete
8702    /// handler triggers the sidecar revoke without pulling in an HTTP-mock crate.
8703    async fn spawn_revoke_sidecar() -> (String, tokio::sync::oneshot::Receiver<String>) {
8704        use tokio::io::{AsyncReadExt, AsyncWriteExt};
8705        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8706        let addr = listener.local_addr().unwrap();
8707        let (tx, rx) = tokio::sync::oneshot::channel::<String>();
8708        tokio::spawn(async move {
8709            let (mut sock, _) = listener.accept().await.unwrap();
8710            let mut buf = vec![0u8; 4096];
8711            let n = sock.read(&mut buf).await.unwrap();
8712            let req = String::from_utf8_lossy(&buf[..n]).to_string();
8713            // Pull the DID out of the JSON body (last line of the request).
8714            let did = req
8715                .split("\r\n\r\n")
8716                .nth(1)
8717                .and_then(|body| {
8718                    let v: serde_json::Value = serde_json::from_str(body.trim()).ok()?;
8719                    v.get("did")?.as_str().map(str::to_string)
8720                })
8721                .unwrap_or_default();
8722            let is_revoke = req.starts_with("POST /internal/revoke");
8723            let body = serde_json::json!({
8724                "ok": true, "did": did, "revoked": true, "hadSession": true
8725            })
8726            .to_string();
8727            let resp = format!(
8728                "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
8729                body.len(),
8730                body
8731            );
8732            sock.write_all(resp.as_bytes()).await.unwrap();
8733            sock.flush().await.unwrap();
8734            let _ = tx.send(if is_revoke { did } else { String::new() });
8735        });
8736        (format!("http://{addr}"), rx)
8737    }
8738
8739    /// Build an [`AppState`] whose sidecar points at `sidecar_url`.
8740    async fn test_state_with_sidecar(allowed: &[&str], sidecar_url: &str) -> AppState {
8741        let defaults = Config::default();
8742        test_state_with_sidecar_and(
8743            allowed,
8744            sidecar_url,
8745            defaults.standard_site,
8746            defaults.max_feeds_global,
8747        )
8748        .await
8749    }
8750
8751    /// [`test_state_with_sidecar`] with the standard.site flag and the global
8752    /// feeds ceiling chosen — the two settings the at:// paths branch on.
8753    async fn test_state_with_sidecar_and(
8754        allowed: &[&str],
8755        sidecar_url: &str,
8756        standard_site: bool,
8757        max_feeds_global: i64,
8758    ) -> AppState {
8759        let db = store::init_url("sqlite::memory:").await.unwrap();
8760        let dids: Vec<String> = allowed.iter().map(|s| s.to_string()).collect();
8761        store::ensure_seed(&db, &dids).await.unwrap();
8762        let mut config = Config {
8763            allowed_dids: dids,
8764            cookie_secret: "test-cookie-secret-000".to_string(),
8765            beta_cap: 3,
8766            standard_site,
8767            max_feeds_global,
8768            ..Config::default()
8769        };
8770        config.sidecar.public_url = sidecar_url.to_string();
8771        config.sidecar.internal_url = sidecar_url.to_string();
8772        AppState::new(config, db).unwrap()
8773    }
8774
8775    /// A confirmed `POST /account/delete` purges the caller's local rows, calls
8776    /// the sidecar revoke for that DID, and clears the session cookie.
8777    #[tokio::test]
8778    async fn account_delete_purges_rows_and_triggers_revoke() {
8779        let (sidecar_url, revoke_rx) = spawn_revoke_sidecar().await;
8780        let did = "did:plc:leaver";
8781        let state = test_state_with_sidecar(&[], &sidecar_url).await;
8782
8783        // Seed the DID with local rows across the per-DID tables.
8784        store::grant_access(&state.db, did, Some("leaver.example"), "test", None)
8785            .await
8786            .unwrap();
8787        store::replace_sub_refs(&state.db, did, &[]).await.unwrap();
8788        store::mint_code(&state.db, did, 3600).await.unwrap();
8789        assert!(store::has_beta_access(&state.db, did).await.unwrap());
8790
8791        let cookie = session_cookie(&state, did, Some("leaver.example"));
8792        let app = router(state.clone());
8793
8794        let resp = app
8795            .oneshot(
8796                Request::builder()
8797                    .method("POST")
8798                    .uri("/account/delete")
8799                    .header(header::COOKIE, cookie)
8800                    .header("content-type", "application/x-www-form-urlencoded")
8801                    .body(Body::from("confirm=DELETE"))
8802                    .unwrap(),
8803            )
8804            .await
8805            .unwrap();
8806
8807        // Signed out: redirect to /login with the cookie cleared.
8808        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8809        assert!(resp
8810            .headers()
8811            .get(header::LOCATION)
8812            .unwrap()
8813            .to_str()
8814            .unwrap()
8815            .starts_with("/login"));
8816        let set_cookie = resp
8817            .headers()
8818            .get(header::SET_COOKIE)
8819            .unwrap()
8820            .to_str()
8821            .unwrap();
8822        assert!(set_cookie.contains("Max-Age=0"), "cookie must be cleared");
8823
8824        // The sidecar revoke was called for exactly this DID.
8825        //
8826        // BOUNDED. A bare `await` here meant a broken `revoke_everywhere` — one
8827        // that simply never called the sidecar — hung this test forever instead
8828        // of failing it: a wedged CI job rather than a red one, which is the
8829        // worse of the two signals because nobody reads it as a defect.
8830        let revoked_did = tokio::time::timeout(std::time::Duration::from_secs(10), revoke_rx)
8831            .await
8832            .expect("the sidecar revoke never fired; revoke_everywhere did not call it")
8833            .unwrap();
8834        assert_eq!(
8835            revoked_did, did,
8836            "sidecar revoke must fire for the caller DID"
8837        );
8838
8839        // Local rows are gone.
8840        assert!(!store::has_beta_access(&state.db, did).await.unwrap());
8841        let codes: i64 =
8842            sqlx::query_scalar("SELECT COUNT(*) FROM invite_codes WHERE creator_did = ?1")
8843                .bind(did)
8844                .fetch_one(&state.db)
8845                .await
8846                .unwrap();
8847        assert_eq!(codes, 0);
8848    }
8849
8850    /// An UN-confirmed `POST /account/delete` (wrong/blank `confirm`) deletes
8851    /// nothing and bounces back to /manage.
8852    #[tokio::test]
8853    async fn account_delete_without_confirm_is_a_noop() {
8854        let did = "did:plc:staying";
8855        let state = test_state(&[]).await;
8856        store::grant_access(&state.db, did, None, "test", None)
8857            .await
8858            .unwrap();
8859        let cookie = session_cookie(&state, did, None);
8860        let app = router(state.clone());
8861
8862        let resp = app
8863            .oneshot(
8864                Request::builder()
8865                    .method("POST")
8866                    .uri("/account/delete")
8867                    .header(header::COOKIE, cookie)
8868                    .header("content-type", "application/x-www-form-urlencoded")
8869                    .body(Body::from("confirm=nope"))
8870                    .unwrap(),
8871            )
8872            .await
8873            .unwrap();
8874
8875        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8876        assert!(resp
8877            .headers()
8878            .get(header::LOCATION)
8879            .unwrap()
8880            .to_str()
8881            .unwrap()
8882            .starts_with("/manage"));
8883        // Nothing deleted.
8884        assert!(store::has_beta_access(&state.db, did).await.unwrap());
8885    }
8886
8887    /// PDS-outage authorization: when the sidecar is unreachable (as it is in
8888    /// this harness — the default sidecar URL is not served), a DID must STILL
8889    /// be unable to read or mutate an entry in a feed it does not subscribe to.
8890    /// This guards the `resolve_subscriptions` fallback: it must fail CLOSED
8891    /// (serve only the DID's own `sub_ref`), never widen the caller's surface to
8892    /// every cached feed.
8893    #[tokio::test]
8894    async fn pds_outage_does_not_widen_cross_did_access() {
8895        let did_a = "did:plc:aaaa";
8896        let state = test_state(&[]).await;
8897        store::grant_access(&state.db, did_a, None, "test", None)
8898            .await
8899            .unwrap();
8900
8901        // Shared cache: feed_a (A subscribes) + feed_b (A does NOT). An entry
8902        // lives in feed_b — the one A must never touch during the outage.
8903        let feed_a = store::upsert_feed(
8904            &state.db,
8905            &store::NewFeed {
8906                url: "https://a.example/feed.xml".to_string(),
8907                title: Some("A".to_string()),
8908                ..Default::default()
8909            },
8910        )
8911        .await
8912        .unwrap();
8913        let feed_b = store::upsert_feed(
8914            &state.db,
8915            &store::NewFeed {
8916                url: "https://b.example/feed.xml".to_string(),
8917                title: Some("B".to_string()),
8918                ..Default::default()
8919            },
8920        )
8921        .await
8922        .unwrap();
8923        store::insert_entries(
8924            &state.db,
8925            feed_b,
8926            &[store::NewEntry {
8927                guid: "b-1".to_string(),
8928                url: Some("https://b.example/1".to_string()),
8929                title: Some("B one".to_string()),
8930                published: Some("2026-07-11T00:00:00Z".to_string()),
8931                content_html: Some("<p>secret B body</p>".to_string()),
8932                ..Default::default()
8933            }],
8934            0,
8935        )
8936        .await
8937        .unwrap();
8938        // A subscribes ONLY to feed_a.
8939        store::replace_sub_refs(&state.db, did_a, &[feed_a])
8940            .await
8941            .unwrap();
8942        // Read B's entry id via a transient sub_ref, then drop it so only the
8943        // shared cache holds B's entry (no DID subscribes to feed_b anymore).
8944        store::replace_sub_refs(&state.db, "did:plc:bbbb", &[feed_b])
8945            .await
8946            .unwrap();
8947        let b_entry_id = store::entries_for_feed(&state.db, "did:plc:bbbb", feed_b)
8948            .await
8949            .unwrap()[0]
8950            .id;
8951        store::replace_sub_refs(&state.db, "did:plc:bbbb", &[])
8952            .await
8953            .unwrap();
8954
8955        let cookie = session_cookie(&state, did_a, None);
8956        let app = router(state.clone());
8957
8958        // GET /entries/{b} as A → 404 even during the outage.
8959        let get_b = app
8960            .clone()
8961            .oneshot(
8962                Request::builder()
8963                    .method("GET")
8964                    .uri(format!("/entries/{b_entry_id}"))
8965                    .header(header::COOKIE, cookie.clone())
8966                    .body(Body::empty())
8967                    .unwrap(),
8968            )
8969            .await
8970            .unwrap();
8971        assert_eq!(
8972            get_b.status(),
8973            StatusCode::NOT_FOUND,
8974            "A must not read B's entry during a PDS outage"
8975        );
8976
8977        // POST /entries/{b}/read as A → 404, and no entry_state row is written.
8978        let read_b = app
8979            .oneshot(
8980                Request::builder()
8981                    .method("POST")
8982                    .uri(format!("/entries/{b_entry_id}/read"))
8983                    .header(header::COOKIE, cookie)
8984                    .header("content-type", "application/x-www-form-urlencoded")
8985                    .body(Body::from("read=true"))
8986                    .unwrap(),
8987            )
8988            .await
8989            .unwrap();
8990        assert_eq!(
8991            read_b.status(),
8992            StatusCode::NOT_FOUND,
8993            "A must not mark B's entry read during a PDS outage"
8994        );
8995
8996        // The fallback must NOT have widened A's sub_ref to feed_b.
8997        let a_feed_ids: Vec<i64> = sqlx::query_scalar("SELECT feed_id FROM sub_ref WHERE did = ?1")
8998            .bind(did_a)
8999            .fetch_all(&state.db)
9000            .await
9001            .unwrap();
9002        assert_eq!(
9003            a_feed_ids,
9004            vec![feed_a],
9005            "outage fallback must not add feeds A never subscribed to"
9006        );
9007        // And B's entry has zero read-state (A's attempt did not mutate).
9008        let es_count: i64 =
9009            sqlx::query_scalar("SELECT COUNT(*) FROM entry_state WHERE did = ?1 AND entry_id = ?2")
9010                .bind(did_a)
9011                .bind(b_entry_id)
9012                .fetch_one(&state.db)
9013                .await
9014                .unwrap();
9015        assert_eq!(es_count, 0, "no cross-DID mutation during the outage");
9016    }
9017
9018    /// **A logout with nothing to revoke is a SUCCESS — on the arm that had
9019    /// nothing. The other arm is counted separately.**
9020    ///
9021    /// Logout is idempotent, so `NoSession` must record as ok; counting it as an
9022    /// error would make the metric noisy in exactly the case that is fine.
9023    ///
9024    /// But `revoke_everywhere` has TWO arms, and a review found that counting
9025    /// only the rust one let `oauth_revoke` report all-clear while every sidecar
9026    /// revocation failed. For anyone who logged in before the cutover the sidecar
9027    /// store is the only one holding tokens, so the rust arm correctly says
9028    /// NoSession and the metric said nothing was wrong. Both arms are now
9029    /// recorded, distinguished by the backend column — so this test pins the
9030    /// BACKEND as well as the outcome.
9031    #[tokio::test]
9032    async fn a_logout_with_no_session_counts_as_success() {
9033        let did = "did:plc:aaaa";
9034        let state = test_state(&[]).await;
9035        assert!(
9036            state.oauth.is_some(),
9037            "meaningless without an oauth runtime; the revoke arm would be skipped",
9038        );
9039
9040        revoke_everywhere(&state, did).await;
9041        let rows = state.metrics.snapshot();
9042        let find = |b: crate::metrics::Backend| {
9043            rows.iter()
9044                .find(|r| r.op == "oauth_revoke" && r.backend == b)
9045                .unwrap_or_else(|| panic!("no oauth_revoke row for {b:?}"))
9046        };
9047
9048        // Rust arm: nothing stored for this DID, so NoSession -> ok.
9049        let rust = find(crate::metrics::Backend::Rust);
9050        assert_eq!(
9051            rust.stats.err_count, 0,
9052            "NoSession was counted as a failure; logout is idempotent",
9053        );
9054        assert_eq!(rust.stats.ok_count, 1);
9055
9056        // Sidecar arm: unreachable in a test, so it must be recorded as an
9057        // ERROR under its own backend — not silently dropped, and not folded
9058        // into the rust row.
9059        let sidecar = find(crate::metrics::Backend::Sidecar);
9060        assert_eq!(
9061            sidecar.stats.err_count, 1,
9062            "a failed sidecar revoke was not counted",
9063        );
9064    }
9065
9066    /// **`Failed` must count as an error — the half the metric exists for.**
9067    ///
9068    /// A review found this unpinned: replacing the mapping with
9069    /// `let revoke_ok = true;` passed all 682 tests. The only revoke test
9070    /// asserted the `NoSession -> ok` half, so the branch that actually means
9071    /// "the PDS still holds tokens we asked it to drop" was untested.
9072    ///
9073    /// Driven through the same handler, with a session present but the PDS
9074    /// unreachable, so `sign_out_discovering` returns `Failed`.
9075    #[tokio::test]
9076    async fn a_failed_rust_revoke_counts_as_an_error() {
9077        let did = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
9078        let state = test_state(&[]).await;
9079        let runtime = state.oauth.as_deref().expect("oauth runtime");
9080        crate::oauth::store::put_session(
9081            &state.db,
9082            &runtime.codec,
9083            &crate::oauth::store::OAuthSession {
9084                sub: did.into(),
9085                issuer: "https://auth.invalid".into(),
9086                aud: "https://pds.invalid".into(),
9087                dpop_key_jwk: crate::oauth::keys::SigningKey::generate("session-dpop")
9088                    .to_jwk_json()
9089                    .unwrap(),
9090                access_token: "at".into(),
9091                refresh_token: "rt".into(),
9092                token_type: "DPoP".into(),
9093                granted_scope: "atproto".into(),
9094                expires_at: Some(crate::store::now_unix() + 3600),
9095            },
9096        )
9097        .await
9098        .unwrap();
9099
9100        revoke_everywhere(&state, did).await;
9101
9102        let rows = state.metrics.snapshot();
9103        let rust = rows
9104            .iter()
9105            .find(|r| r.op == "oauth_revoke" && r.backend == crate::metrics::Backend::Rust)
9106            .expect("no rust oauth_revoke row");
9107        assert_eq!(
9108            rust.stats.err_count, 1,
9109            "an unreachable PDS must count as a revocation failure",
9110        );
9111        assert_eq!(rust.stats.ok_count, 0);
9112    }
9113
9114    /// **The `href` defence is now carried by the TYPE, not by remembering.**
9115    ///
9116    /// `EntryRow.link` used to be a `String`, and the guard was "call
9117    /// `net::safe_link` before assigning it". Deleting that call left all 679
9118    /// tests passing — a live XSS defence with nothing protecting it.
9119    ///
9120    /// `SafeLink` has no `From<String>` and no public member, so the only way to
9121    /// get foreign input into an `href` is `external`, which does the check
9122    /// itself. This test pins that constructor; the *wiring* is now pinned by
9123    /// the compiler, which is the part a test could never hold down.
9124    ///
9125    /// Note the empty-not-absent behaviour: a rejected URL yields an EMPTY link
9126    /// so the template renders the row WITHOUT an anchor. Dropping the row
9127    /// instead would make the record unremovable, because the un-save button
9128    /// lives on it.
9129    #[test]
9130    fn a_hostile_scheme_cannot_reach_an_href_through_safelink() {
9131        for hostile in [
9132            "javascript:alert(1)",
9133            "JavaScript:alert(1)",
9134            "  javascript:alert(1)",
9135            "data:text/html;base64,PHNjcmlwdD4=",
9136            "vbscript:msgbox(1)",
9137            "file:///etc/passwd",
9138            // Protocol-relative: inherits the page's scheme, so it is an
9139            // off-site link wearing a same-site costume. Carried over from the
9140            // test this one replaces, which was its only unique input.
9141            "//evil.example/path",
9142        ] {
9143            let link = SafeLink::external(hostile);
9144            assert!(
9145                link.is_empty(),
9146                "{hostile:?} produced a non-empty href: {link}",
9147            );
9148            assert!(
9149                !link.to_string().to_ascii_lowercase().contains("script"),
9150                "{hostile:?} leaked into the rendered link",
9151            );
9152        }
9153
9154        // And the other direction: a check that rejects everything would satisfy
9155        // the loop above while breaking every real saved record.
9156        for good in ["https://example.com/a?b=c#d", "http://example.com/"] {
9157            let link = SafeLink::external(good);
9158            assert!(!link.is_empty(), "{good:?} was wrongly rejected");
9159            assert_eq!(link.to_string(), good);
9160        }
9161    }
9162
9163    /// **The WIRING, not the helper — this is the one that catches the real
9164    /// mistake.**
9165    ///
9166    /// `a_hostile_scheme_cannot_reach_an_href_through_safelink` pins what
9167    /// `SafeLink::external` *does*. It cannot pin that the saved-record path
9168    /// *calls* it, and a review proved that gap was live twice over: swapping
9169    /// `external` for the app-path constructor, and constructing the tuple
9170    /// directly, both restored the whole `javascript:` hole with every test
9171    /// green. The type now blocks both — `entry` takes an `i64`, and the field
9172    /// lives in another module — but the wiring deserves a test of its own
9173    /// rather than resting on the shape of a signature.
9174    ///
9175    /// Renders the actual row through the actual handler, from a record whose
9176    /// URL is hostile.
9177    #[tokio::test]
9178    async fn a_saved_record_with_a_hostile_url_renders_no_anchor() {
9179        let did = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
9180        let sidecar = spawn_saved_sidecar("javascript:alert(1)", "Hostile record").await;
9181        let mut state = test_state_with_sidecar(&[did], &sidecar).await;
9182        std::sync::Arc::get_mut(&mut state.config).unwrap().dev_did = Some(did.to_string());
9183
9184        let resp = router(state)
9185            .oneshot(
9186                Request::builder()
9187                    .uri("/?view=starred")
9188                    .body(Body::empty())
9189                    .unwrap(),
9190            )
9191            .await
9192            .unwrap();
9193        assert_eq!(resp.status(), StatusCode::OK);
9194        let body = String::from_utf8(
9195            axum::body::to_bytes(resp.into_body(), usize::MAX)
9196                .await
9197                .unwrap()
9198                .to_vec(),
9199        )
9200        .unwrap();
9201
9202        // Not in an href, and not as the title either — the title falls back to
9203        // the URL for links we DO render, so both paths must withhold it.
9204        assert!(
9205            !body.to_ascii_lowercase().contains("javascript:"),
9206            "the hostile scheme reached the rendered page",
9207        );
9208        // But the row must survive: the un-save button lives on it, so dropping
9209        // the row would make the record unremovable from here.
9210        assert!(
9211            body.contains("unusable link"),
9212            "the row was dropped instead of rendering without an anchor",
9213        );
9214    }
9215
9216    /// **The reader view's two `href`s, through the actual handler.**
9217    ///
9218    /// The sibling above covers the LIST row. `entry.html` has its own pair of
9219    /// `href`s fed by `EntryTemplate.url`, and they were a raw `Option<String>`
9220    /// taken straight off the `entries.url` column — a remote feed's `<link>`.
9221    ///
9222    /// Ingest already scheme-checks that column (`feed.rs`'s `entry_link`), so
9223    /// this was never a live hole. But that guard is procedural and sits a long
9224    /// way from the `href`: it holds only as long as every future writer to
9225    /// `entries.url` remembers to go through `feed.rs`. This test does not
9226    /// depend on it — it writes the hostile URL into the column DIRECTLY, which
9227    /// is precisely the state the ingest check cannot speak for.
9228    ///
9229    /// **Both directions, deliberately.** A fix that renders no link at all
9230    /// satisfies every negative assertion here, and would break every real
9231    /// entry. The second half is what makes the first half mean something.
9232    #[tokio::test]
9233    async fn a_hostile_entry_url_renders_the_reader_without_an_original_link() {
9234        let did = "did:plc:readerhref";
9235        let state = test_state(&[]).await;
9236        store::grant_access(&state.db, did, None, "test", None)
9237            .await
9238            .unwrap();
9239        let feed = store::upsert_feed(
9240            &state.db,
9241            &store::NewFeed {
9242                url: "https://href.example/feed.xml".to_string(),
9243                title: Some("Href".to_string()),
9244                ..Default::default()
9245            },
9246        )
9247        .await
9248        .unwrap();
9249        // Straight into the column, bypassing `feed.rs` — the whole point.
9250        store::insert_entries(
9251            &state.db,
9252            feed,
9253            &[
9254                store::NewEntry {
9255                    guid: "hostile-1".to_string(),
9256                    url: Some("javascript:alert(1)".to_string()),
9257                    title: Some("Hostile entry".to_string()),
9258                    published: Some("2026-07-11T00:00:00Z".to_string()),
9259                    ..Default::default()
9260                },
9261                store::NewEntry {
9262                    guid: "benign-1".to_string(),
9263                    url: Some("https://href.example/post".to_string()),
9264                    title: Some("Benign entry".to_string()),
9265                    published: Some("2026-07-10T00:00:00Z".to_string()),
9266                    ..Default::default()
9267                },
9268            ],
9269            0,
9270        )
9271        .await
9272        .unwrap();
9273        store::replace_sub_refs(&state.db, did, &[feed])
9274            .await
9275            .unwrap();
9276        let rows = store::entries_for_feed(&state.db, did, feed).await.unwrap();
9277        let id_of = |guid: &str| {
9278            rows.iter()
9279                .find(|r| r.guid == guid)
9280                .unwrap_or_else(|| panic!("{guid} was not inserted"))
9281                .id
9282        };
9283
9284        let cookie = session_cookie(&state, did, None);
9285        let app = router(state.clone());
9286
9287        let render = |id: i64| {
9288            let app = app.clone();
9289            let cookie = cookie.clone();
9290            async move {
9291                let resp = app
9292                    .oneshot(
9293                        Request::builder()
9294                            .method("GET")
9295                            .uri(format!("/entries/{id}"))
9296                            .header(header::COOKIE, cookie)
9297                            .body(Body::empty())
9298                            .unwrap(),
9299                    )
9300                    .await
9301                    .unwrap();
9302                assert_eq!(resp.status(), StatusCode::OK);
9303                String::from_utf8(
9304                    axum::body::to_bytes(resp.into_body(), usize::MAX)
9305                        .await
9306                        .unwrap()
9307                        .to_vec(),
9308                )
9309                .unwrap()
9310            }
9311        };
9312
9313        let hostile = render(id_of("hostile-1")).await;
9314        // The reader page for THIS entry actually rendered. Without this the
9315        // three negatives below are satisfied by an empty body.
9316        assert!(
9317            hostile.contains("Hostile entry"),
9318            "the reader did not render the entry: {hostile}",
9319        );
9320        assert!(
9321            !hostile.to_ascii_lowercase().contains("javascript:"),
9322            "the hostile scheme reached the reader page: {hostile}",
9323        );
9324        // Not merely escaped — the template took its no-link branch. Both
9325        // `href`s are gated on the same `Option`, so this covers the byline
9326        // link and the action-bar button together.
9327        assert!(
9328            !hostile.contains("actionbar-open"),
9329            "the action bar rendered an open-original link for a refused URL: {hostile}",
9330        );
9331        assert!(
9332            !hostile.contains("Original \u{2197}"),
9333            "the byline rendered an original link for a refused URL: {hostile}",
9334        );
9335
9336        // The other direction: a legitimate entry still links out, so "render
9337        // nothing" cannot pass as a fix.
9338        let benign = render(id_of("benign-1")).await;
9339        assert!(
9340            benign.contains("Benign entry"),
9341            "the reader did not render the benign entry: {benign}",
9342        );
9343        // BOTH `href`s, counted. The negatives above fire on the action bar
9344        // first, so without this the byline needle `Original \u{2197}` is never
9345        // once observed failing — a misspelled needle would pass forever.
9346        assert_eq!(
9347            benign
9348                .matches(r#"href="https://href.example/post""#)
9349                .count(),
9350            2,
9351            "entry.html has two `href`s for the entry URL — the byline link and \
9352             the action-bar button — and this render produced a different \
9353             number: {benign}",
9354        );
9355        assert!(
9356            benign.contains("actionbar-open"),
9357            "a legitimate entry lost its open-original button: {benign}",
9358        );
9359        assert!(
9360            benign.contains("Original \u{2197}"),
9361            "a legitimate entry lost its byline link: {benign}",
9362        );
9363    }
9364
9365    /// **The outage fallback must not widen what the caller can READ — and the
9366    /// sibling test above can only see what it WRITES.**
9367    ///
9368    /// `pds_outage_does_not_widen_cross_did_access` asserts on `sub_ref` rows and
9369    /// on `entry_state`: the fallback's side effects. But the fail-open it names
9370    /// returns **early**, before `sync_sub_refs` runs, so it touches neither. It
9371    /// leaks through the list it *hands back* — the sidebar and the reader render
9372    /// from that list, so a caller sees another DID's feeds while `sub_ref` stays
9373    /// perfectly honest and every existing assertion stays green.
9374    ///
9375    /// Measured, not assumed: replacing `feeds_for_did(pool, did)` with
9376    /// `due_feeds(pool, "9999-…", 10_000)` over the whole shared cache — the
9377    /// exact historical bug the fallback's comment describes — left **all 663
9378    /// tests passing**. Cross-tenant isolation is the one property this project
9379    /// cannot regress quietly, and nothing observed it.
9380    ///
9381    /// So this asserts on the RETURN VALUE, which is the thing that reaches the
9382    /// user, and it deliberately does not look at `sub_ref` at all — that half is
9383    /// already covered above.
9384    #[tokio::test]
9385    async fn the_outage_fallback_returns_only_the_callers_own_feeds() {
9386        let did_a = "did:plc:aaaa";
9387        let state = test_state(&[]).await;
9388        store::grant_access(&state.db, did_a, None, "test", None)
9389            .await
9390            .unwrap();
9391
9392        let feed_a = store::upsert_feed(
9393            &state.db,
9394            &store::NewFeed {
9395                url: "https://a.example/feed.xml".to_string(),
9396                title: Some("A".to_string()),
9397                ..Default::default()
9398            },
9399        )
9400        .await
9401        .unwrap();
9402        let _feed_b = store::upsert_feed(
9403            &state.db,
9404            &store::NewFeed {
9405                url: "https://b.example/feed.xml".to_string(),
9406                title: Some("B".to_string()),
9407                ..Default::default()
9408            },
9409        )
9410        .await
9411        .unwrap();
9412        // A subscribes ONLY to feed_a. feed_b is in the shared cache and belongs
9413        // to nobody — exactly the row a whole-cache fallback would hand to A.
9414        store::replace_sub_refs(&state.db, did_a, &[feed_a])
9415            .await
9416            .unwrap();
9417
9418        // No sidecar and no PDS are reachable from a test, so
9419        // `list_subscriptions_sorted` fails and this IS the outage path. Assert
9420        // that, rather than assuming it: if the repo ever starts succeeding here,
9421        // this test would silently stop exercising the fallback at all.
9422        assert!(
9423            state.repo().list_subscriptions_sorted(did_a).await.is_err(),
9424            "this test is only meaningful on the outage path; the repo answered",
9425        );
9426
9427        let resolved = resolve_subscriptions(&state, did_a).await;
9428
9429        let urls: Vec<&str> = resolved.iter().map(|r| r.sub.url.as_str()).collect();
9430        assert_eq!(
9431            urls,
9432            vec!["https://a.example/feed.xml"],
9433            "the outage fallback must return the caller's OWN subscriptions only; \
9434             any other feed here is cross-tenant read access granted by an outage",
9435        );
9436    }
9437
9438    /// Build an [`AppState`] over a fresh in-memory DB with explicit feed caps,
9439    /// seeding `did` a beta seat + session-capable state.
9440    async fn test_state_with_caps(
9441        did: &str,
9442        max_subs_per_did: i64,
9443        max_feeds_global: i64,
9444    ) -> AppState {
9445        let db = store::init_url("sqlite::memory:").await.unwrap();
9446        let config = Config {
9447            cookie_secret: "test-cookie-secret-000".to_string(),
9448            beta_cap: 100,
9449            max_subs_per_did,
9450            max_feeds_global,
9451            ..Config::default()
9452        };
9453        store::grant_access(&db, did, None, "test", None)
9454            .await
9455            .unwrap();
9456        AppState::new(config, db).unwrap()
9457    }
9458
9459    /// An OPML document with `n` distinct public feeds.
9460    fn opml_with_feeds(n: usize) -> String {
9461        let mut outlines = String::new();
9462        for i in 0..n {
9463            outlines.push_str(&format!(
9464                "<outline type=\"rss\" text=\"F{i}\" xmlUrl=\"https://f{i}.example/feed.xml\"/>\n"
9465            ));
9466        }
9467        format!(
9468            "<?xml version=\"1.0\"?>\n<opml version=\"2.0\"><head><title>t</title></head><body>\n{outlines}</body></opml>"
9469        )
9470    }
9471
9472    /// OPML bulk import must honour the GLOBAL feeds ceiling: importing more
9473    /// distinct new feeds than the shared cache can hold caches only up to the
9474    /// ceiling — the rest are trimmed. (Regression: the import loop previously
9475    /// bypassed `max_feeds_global` entirely.)
9476    #[tokio::test]
9477    async fn opml_import_enforces_global_feeds_ceiling() {
9478        let did = "did:plc:importer";
9479        // Cap the shared cache at 3 feeds; import 10 distinct new ones.
9480        let state = test_state_with_caps(did, 0, 3).await;
9481        let cookie = session_cookie(&state, did, None);
9482        let (ct, body) = opml_multipart(opml_with_feeds(10).as_bytes());
9483        let app = router(state.clone());
9484
9485        let resp = app
9486            .oneshot(
9487                Request::builder()
9488                    .method("POST")
9489                    .uri("/opml")
9490                    .header(header::COOKIE, cookie)
9491                    .header("content-type", ct)
9492                    .body(Body::from(body))
9493                    .unwrap(),
9494            )
9495            .await
9496            .unwrap();
9497        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9498
9499        let feeds = store::count_feeds(&state.db).await.unwrap();
9500        assert!(
9501            feeds <= 3,
9502            "OPML import blew past the global ceiling: {feeds} feeds cached with cap=3"
9503        );
9504    }
9505
9506    /// POST an OPML of `n` feeds as `did` against a strict fake PDS behind the
9507    /// sidecar, and return the flash it redirected with plus the fake's log.
9508    async fn import_against_strict_pds(
9509        did: &str,
9510        n: usize,
9511        fail_call: Option<usize>,
9512    ) -> (String, crate::atproto::tests::ApplyWritesLog) {
9513        let (sidecar, log) = crate::atproto::tests::serve_apply_writes(fail_call).await;
9514        let state = test_state_with_sidecar(&[did], &sidecar).await;
9515        let cookie = session_cookie(&state, did, None);
9516        let (ct, body) = opml_multipart(opml_with_feeds(n).as_bytes());
9517        let resp = router(state)
9518            .oneshot(
9519                Request::builder()
9520                    .method("POST")
9521                    .uri("/opml")
9522                    .header(header::COOKIE, cookie)
9523                    .header("content-type", ct)
9524                    .body(Body::from(body))
9525                    .unwrap(),
9526            )
9527            .await
9528            .unwrap();
9529        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9530        let loc = resp.headers()[header::LOCATION].to_str().unwrap();
9531        let flash = url::Url::parse(&format!("http://x{loc}"))
9532            .unwrap()
9533            .query_pairs()
9534            .find(|(k, _)| k == "flash")
9535            .map(|(_, v)| v.into_owned())
9536            .unwrap_or_default();
9537        (flash, log)
9538    }
9539
9540    /// **An OPML import of more than 200 feeds succeeds** against a PDS that
9541    /// refuses more than 200 writes a call, as the reference PDS does. It used
9542    /// to go out as one `applyWrites` and fail outright, importing nothing.
9543    #[tokio::test]
9544    async fn opml_import_of_450_feeds_succeeds_against_a_pds_capping_at_200() {
9545        let (flash, log) = import_against_strict_pds("did:plc:bigimport", 450, None).await;
9546        assert_eq!(flash, "Imported 450 feeds", "{flash}");
9547        assert_eq!(crate::atproto::tests::call_sizes(&log), vec![200, 200, 50]);
9548    }
9549
9550    /// **A part-landed import says so.** Chunk 2 of 3 fails: chunk 1's 200
9551    /// feeds are in the reader's repo, and "nothing was imported" — what the
9552    /// handler said for any failure — would be false.
9553    #[tokio::test]
9554    async fn opml_import_that_part_lands_reports_what_landed() {
9555        let (flash, log) = import_against_strict_pds("did:plc:partimport", 450, Some(2)).await;
9556        assert_eq!(crate::atproto::tests::call_sizes(&log), vec![200, 200]);
9557        assert!(
9558            flash.contains("200 of 450"),
9559            "the landed count is not reported: {flash}"
9560        );
9561        assert!(
9562            !flash.contains("nothing was imported"),
9563            "200 feeds landed and the reader was told none did: {flash}"
9564        );
9565    }
9566
9567    /// A batch that failed on its first call still reports that nothing was
9568    /// imported — true, since nothing after a failed call is sent.
9569    #[tokio::test]
9570    async fn opml_import_that_fails_on_the_first_call_imports_nothing() {
9571        let (flash, log) = import_against_strict_pds("did:plc:noimport", 450, Some(1)).await;
9572        assert_eq!(crate::atproto::tests::call_sizes(&log), vec![200]);
9573        assert!(flash.contains("nothing was imported"), "{flash}");
9574    }
9575
9576    /// **A malformed `at://` on the add path is "not a kind of feed we take",
9577    /// not "private/paid".** The first gate was the privacy classifier, whose
9578    /// at:// arm fails closed as `Private` for anything not a well-formed
9579    /// publication URI — so a typo (`did:plc:TOOSHORT`, a missing rkey) drew
9580    /// the private-feed flash and a "refused private/paid feed" log line. On
9581    /// main the same input reached `resolve_feed_url` and got "Couldn't find a
9582    /// feed". Storability is decided first for an at:// input, with its own
9583    /// message.
9584    #[tokio::test]
9585    async fn a_malformed_at_uri_on_the_add_path_is_refused_as_unsupported_not_private() {
9586        let did = "did:plc:typoist";
9587        let state = test_state_with_caps(did, 0, 0).await;
9588        let cookie = session_cookie(&state, did, None);
9589        for input in [
9590            "at%3A%2F%2Falice.example.com%2Fsite.standard.publication",
9591            "at%3A%2F%2Fdid%3Aplc%3ATOOSHORT%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
9592        ] {
9593            let resp = router(state.clone())
9594                .oneshot(
9595                    Request::builder()
9596                        .method("POST")
9597                        .uri("/subscriptions")
9598                        .header(header::COOKIE, cookie.clone())
9599                        .header("content-type", "application/x-www-form-urlencoded")
9600                        .body(Body::from(format!("url={input}")))
9601                        .unwrap(),
9602                )
9603                .await
9604                .unwrap();
9605            assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9606            let loc = resp
9607                .headers()
9608                .get(header::LOCATION)
9609                .unwrap()
9610                .to_str()
9611                .unwrap();
9612            assert!(
9613                loc.contains("kind%20of%20feed"),
9614                "expected the unsupported-feed flash for {input}, got {loc}"
9615            );
9616            assert!(
9617                !loc.contains("Private"),
9618                "a storability refusal was reported as a privacy one for {input}: {loc}"
9619            );
9620        }
9621        assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
9622    }
9623
9624    /// **An OPML entry this instance cannot store is counted and reported, not
9625    /// silently dropped.** The storability `continue` incremented nothing,
9626    /// while the privacy branch beside it produced a user-visible label — so
9627    /// an OPML exported from a standard.site-enabled instance imported
9628    /// "successfully" with entries missing and no reason given. The reader is
9629    /// told how many, and why.
9630    #[tokio::test]
9631    async fn opml_import_reports_entries_this_instance_cannot_store() {
9632        let did = "did:plc:renamer4";
9633        let (sidecar, _puts) = spawn_rename_sidecar(seeded_subscription()).await;
9634        let state = test_state_with_sidecar(&[did], &sidecar).await;
9635        assert!(!state.config.standard_site);
9636        let opml = format!(
9637            "<?xml version=\"1.0\"?>\n<opml version=\"2.0\"><head><title>t</title></head><body>\n\
9638             <outline type=\"rss\" text=\"Real\" xmlUrl=\"https://real.example/feed.xml\"/>\n\
9639             <outline type=\"rss\" text=\"Pub\" xmlUrl=\"{AT_URI_SUB}\"/>\n\
9640             </body></opml>"
9641        );
9642        let (ct, body) = opml_multipart(opml.as_bytes());
9643        let cookie = session_cookie(&state, did, None);
9644        let resp = router(state.clone())
9645            .oneshot(
9646                Request::builder()
9647                    .method("POST")
9648                    .uri("/opml")
9649                    .header(header::COOKIE, cookie)
9650                    .header("content-type", ct)
9651                    .body(Body::from(body))
9652                    .unwrap(),
9653            )
9654            .await
9655            .unwrap();
9656        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9657        let loc = resp
9658            .headers()
9659            .get(header::LOCATION)
9660            .unwrap()
9661            .to_str()
9662            .unwrap();
9663        assert!(
9664            loc.contains("Imported%201%20feed"),
9665            "unexpected flash: {loc}"
9666        );
9667        assert!(
9668            loc.contains("1%20feed%28s%29%20skipped") && loc.contains("can%20subscribe%20to"),
9669            "the dropped entry was not reported: {loc}"
9670        );
9671        // Reported by count only: the at-URI itself is not echoed back.
9672        assert!(
9673            !loc.contains("site.standard.publication"),
9674            "the URI was echoed: {loc}"
9675        );
9676    }
9677
9678    /// OPML bulk import must honour the PER-DID subscription cap: a DID at its
9679    /// cap imports zero new feeds.
9680    #[tokio::test]
9681    async fn opml_import_enforces_per_did_cap() {
9682        let did = "did:plc:capped";
9683        // Per-DID cap 2, global unlimited. Pre-seed the DID at its cap.
9684        let state = test_state_with_caps(did, 2, 0).await;
9685        let existing_a = store::upsert_feed(
9686            &state.db,
9687            &store::NewFeed {
9688                url: "https://have-a.example/feed.xml".to_string(),
9689                ..Default::default()
9690            },
9691        )
9692        .await
9693        .unwrap();
9694        let existing_b = store::upsert_feed(
9695            &state.db,
9696            &store::NewFeed {
9697                url: "https://have-b.example/feed.xml".to_string(),
9698                ..Default::default()
9699            },
9700        )
9701        .await
9702        .unwrap();
9703        store::replace_sub_refs(&state.db, did, &[existing_a, existing_b])
9704            .await
9705            .unwrap();
9706        let before = store::count_feeds(&state.db).await.unwrap();
9707
9708        let cookie = session_cookie(&state, did, None);
9709        let (ct, body) = opml_multipart(opml_with_feeds(10).as_bytes());
9710        let app = router(state.clone());
9711        let resp = app
9712            .oneshot(
9713                Request::builder()
9714                    .method("POST")
9715                    .uri("/opml")
9716                    .header(header::COOKIE, cookie)
9717                    .header("content-type", ct)
9718                    .body(Body::from(body))
9719                    .unwrap(),
9720            )
9721            .await
9722            .unwrap();
9723        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9724        // Headroom was 0 → no new feeds imported into the shared cache.
9725        let after = store::count_feeds(&state.db).await.unwrap();
9726        assert_eq!(after, before, "over-cap DID imported new feeds anyway");
9727    }
9728
9729    /// Single-add per-DID cap: a DID at its subscription cap is refused before
9730    /// any fetch, with the limit flash.
9731    #[tokio::test]
9732    async fn single_add_enforces_per_did_cap() {
9733        let did = "did:plc:subcapped";
9734        let state = test_state_with_caps(did, 1, 0).await;
9735        let f = store::upsert_feed(
9736            &state.db,
9737            &store::NewFeed {
9738                url: "https://have.example/feed.xml".to_string(),
9739                ..Default::default()
9740            },
9741        )
9742        .await
9743        .unwrap();
9744        store::replace_sub_refs(&state.db, did, &[f]).await.unwrap();
9745        let cookie = session_cookie(&state, did, None);
9746        let app = router(state.clone());
9747        let resp = app
9748            .oneshot(
9749                Request::builder()
9750                    .method("POST")
9751                    .uri("/subscriptions")
9752                    .header(header::COOKIE, cookie)
9753                    .header("content-type", "application/x-www-form-urlencoded")
9754                    .body(Body::from("url=https://another.example/feed.xml"))
9755                    .unwrap(),
9756            )
9757            .await
9758            .unwrap();
9759        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9760        let loc = resp
9761            .headers()
9762            .get(header::LOCATION)
9763            .unwrap()
9764            .to_str()
9765            .unwrap();
9766        assert!(
9767            loc.contains("Subscription%20limit%20reached"),
9768            "expected sub-limit flash, got {loc}"
9769        );
9770    }
9771
9772    /// `GET /` renders at most one page of rows and offers a way to the rest.
9773    ///
9774    /// The handler used to materialize EVERY unread entry — `SELECT e.*`, no
9775    /// `LIMIT`, article bodies included — and hand the lot to the template. With
9776    /// 250 entries that is the whole list in one response; with a real backlog on
9777    /// a 512 MB box it is the OOM the operator review flagged. Asserts the page
9778    /// is capped, the heading still reports the true total, and page 2 is
9779    /// reachable and disjoint.
9780    #[tokio::test]
9781    async fn the_reader_index_pages_instead_of_rendering_everything() {
9782        let did = "did:plc:pager";
9783        let state = test_state(&[]).await;
9784        store::grant_access(&state.db, did, None, "test", None)
9785            .await
9786            .unwrap();
9787        let feed = store::upsert_feed(
9788            &state.db,
9789            &store::NewFeed {
9790                url: "https://pager.example/feed.xml".to_string(),
9791                title: Some("Pager".to_string()),
9792                ..Default::default()
9793            },
9794        )
9795        .await
9796        .unwrap();
9797        let total = 250_usize;
9798        let entries: Vec<store::NewEntry> = (0..total)
9799            .map(|i| store::NewEntry {
9800                guid: format!("p-{i:04}"),
9801                url: Some(format!("https://pager.example/{i}")),
9802                title: Some(format!("Article {i:04}")),
9803                published: Some(format!("2026-07-{:02}T00:00:00Z", (i % 28) + 1)),
9804                content_html: Some("x".repeat(4_000)),
9805                ..Default::default()
9806            })
9807            .collect();
9808        store::insert_entries(&state.db, feed, &entries, 0)
9809            .await
9810            .unwrap();
9811        store::replace_sub_refs(&state.db, did, &[feed])
9812            .await
9813            .unwrap();
9814
9815        let cookie = session_cookie(&state, did, None);
9816        let app = router(state.clone());
9817        let get = |uri: &str| {
9818            let app = app.clone();
9819            let cookie = cookie.clone();
9820            let uri = uri.to_string();
9821            async move {
9822                let resp = app
9823                    .oneshot(
9824                        Request::builder()
9825                            .uri(uri)
9826                            .header(header::COOKIE, cookie)
9827                            .body(Body::empty())
9828                            .unwrap(),
9829                    )
9830                    .await
9831                    .unwrap();
9832                assert_eq!(resp.status(), StatusCode::OK);
9833                let bytes = axum::body::to_bytes(resp.into_body(), 8 * 1024 * 1024)
9834                    .await
9835                    .unwrap();
9836                String::from_utf8(bytes.to_vec()).unwrap()
9837            }
9838        };
9839
9840        let page1 = get("/").await;
9841        // One `<li class="entry…>` per rendered row. Counting "/entries/" would
9842        // over-count: each row carries several (the link plus the read/star
9843        // forms).
9844        let rows1 = page1.matches("<li class=\"entry").count();
9845        assert!(
9846            rows1 <= ENTRIES_PER_PAGE as usize,
9847            "page 1 rendered {rows1} entry links; the list is unbounded"
9848        );
9849        assert!(
9850            rows1 > 0,
9851            "page 1 rendered nothing at all: the page bound swallowed the list"
9852        );
9853        // The count is the TRUE total, not the page size — otherwise paging
9854        // would quietly relabel a 250-entry backlog as a 100-entry one.
9855        assert!(
9856            page1.contains("250 entries"),
9857            "heading must report the full total, not the page"
9858        );
9859        assert!(
9860            page1.contains("page=2"),
9861            "no way to reach the rest of the list: {}",
9862            &page1[..page1.len().min(400)]
9863        );
9864        // The body never belongs in a list response.
9865        assert!(
9866            !page1.contains(&"x".repeat(4_000)),
9867            "the list response carried an article body"
9868        );
9869
9870        let page2 = get("/?page=2").await;
9871        assert!(
9872            page2.matches("<li class=\"entry").count() > 0,
9873            "page 2 rendered no rows at all"
9874        );
9875        assert!(
9876            page2.contains("page=1") || page2.contains("Newer"),
9877            "page 2 offers no way back"
9878        );
9879        // Disjoint: an article on page 1 must not reappear on page 2.
9880        let first_title = (0..total)
9881            .map(|i| format!("Article {i:04}"))
9882            .find(|t| page1.contains(t))
9883            .expect("page 1 shows at least one titled article");
9884        assert!(
9885            !page2.contains(&first_title),
9886            "{first_title} appears on both pages"
9887        );
9888
9889        // A page past the end must not be a dead end. The empty state renders
9890        // instead of the pager, so an out-of-range page would leave a reader
9891        // with no link back — reachable by typing a number, and reachable
9892        // WITHOUT typing anything by paging to the end and then marking entries
9893        // read, which shrinks the list under the URL already in the address bar.
9894        let past_end = get("/?page=999").await;
9895        assert!(
9896            past_end.matches("<li class=\"entry").count() > 0,
9897            "an out-of-range page rendered nothing and offered no way back"
9898        );
9899        assert!(
9900            past_end.contains("page=2"),
9901            "the clamped page offers no pager"
9902        );
9903    }
9904
9905    /// Reader-view mark-read (a request tagged `X-FR-Reader: 1`) must return the
9906    /// out-of-band action-bar fragment with FRESHLY re-read state so a second
9907    /// keypress reverses the toggle: `hx-swap-oob="outerHTML"` is present, and
9908    /// the hidden `read` input + `aria-pressed` reflect the NEW state. The list
9909    /// view (no reader header) instead swaps the row. This guards the reader OOB
9910    /// toggle wiring, which had no test.
9911    #[tokio::test]
9912    async fn reader_mark_read_returns_oob_actionbar_with_flipped_state() {
9913        let did = "did:plc:reader";
9914        let state = test_state(&[]).await;
9915        store::grant_access(&state.db, did, None, "test", None)
9916            .await
9917            .unwrap();
9918        let feed = store::upsert_feed(
9919            &state.db,
9920            &store::NewFeed {
9921                url: "https://reader.example/feed.xml".to_string(),
9922                title: Some("Reader".to_string()),
9923                ..Default::default()
9924            },
9925        )
9926        .await
9927        .unwrap();
9928        store::insert_entries(
9929            &state.db,
9930            feed,
9931            &[store::NewEntry {
9932                guid: "r-1".to_string(),
9933                url: Some("https://reader.example/1".to_string()),
9934                title: Some("Article".to_string()),
9935                published: Some("2026-07-11T00:00:00Z".to_string()),
9936                content_html: Some("<p>body</p>".to_string()),
9937                ..Default::default()
9938            }],
9939            0,
9940        )
9941        .await
9942        .unwrap();
9943        store::replace_sub_refs(&state.db, did, &[feed])
9944            .await
9945            .unwrap();
9946        let entry_id = store::entries_for_feed(&state.db, did, feed).await.unwrap()[0].id;
9947
9948        let cookie = session_cookie(&state, did, None);
9949        let app = router(state.clone());
9950
9951        // Reader-tagged mark-read → OOB action-bar fragment, entry now READ.
9952        let resp = app
9953            .clone()
9954            .oneshot(
9955                Request::builder()
9956                    .method("POST")
9957                    .uri(format!("/entries/{entry_id}/read"))
9958                    .header(header::COOKIE, cookie.clone())
9959                    .header("HX-Request", "true")
9960                    .header("X-FR-Reader", "1")
9961                    .header("content-type", "application/x-www-form-urlencoded")
9962                    .body(Body::from("read=true"))
9963                    .unwrap(),
9964            )
9965            .await
9966            .unwrap();
9967        assert_eq!(resp.status(), StatusCode::OK);
9968        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
9969            .await
9970            .unwrap();
9971        let html = String::from_utf8(bytes.to_vec()).unwrap();
9972        assert!(
9973            html.contains("hx-swap-oob=\"outerHTML\""),
9974            "reader response must be an OOB swap: {html}"
9975        );
9976        assert!(
9977            html.contains(r#"id="entry-actionbar""#),
9978            "reader response must be the action-bar fragment: {html}"
9979        );
9980        // Now READ: the read button reflects it (aria-pressed=true) and the
9981        // hidden value flips to `false` so the next tap marks it UNREAD.
9982        assert!(
9983            html.contains(r#"aria-pressed="true""#),
9984            "read button must show pressed after marking read: {html}"
9985        );
9986        assert!(
9987            html.contains(r#"name="read" value="false""#),
9988            "hidden read value must flip to false so a second tap reverses: {html}"
9989        );
9990
9991        // A second reader mark-read (submitting the flipped `read=false`) marks
9992        // it UNREAD again — the toggle reverses.
9993        let resp2 = app
9994            .oneshot(
9995                Request::builder()
9996                    .method("POST")
9997                    .uri(format!("/entries/{entry_id}/read"))
9998                    .header(header::COOKIE, cookie)
9999                    .header("HX-Request", "true")
10000                    .header("X-FR-Reader", "1")
10001                    .header("content-type", "application/x-www-form-urlencoded")
10002                    .body(Body::from("read=false"))
10003                    .unwrap(),
10004            )
10005            .await
10006            .unwrap();
10007        assert_eq!(resp2.status(), StatusCode::OK);
10008        let bytes2 = axum::body::to_bytes(resp2.into_body(), 64 * 1024)
10009            .await
10010            .unwrap();
10011        let html2 = String::from_utf8(bytes2.to_vec()).unwrap();
10012        assert!(
10013            html2.contains(r#"aria-pressed="false""#),
10014            "read button must show un-pressed after reversing: {html2}"
10015        );
10016        assert!(
10017            html2.contains(r#"name="read" value="true""#),
10018            "hidden read value must flip back to true: {html2}"
10019        );
10020    }
10021
10022    /// The LIST view (no `X-FR-Reader` header) swaps the row, not the OOB
10023    /// action-bar — the counterpart to the reader-OOB test above.
10024    #[tokio::test]
10025    async fn list_mark_read_returns_row_not_oob_actionbar() {
10026        let did = "did:plc:listv";
10027        let state = test_state(&[]).await;
10028        store::grant_access(&state.db, did, None, "test", None)
10029            .await
10030            .unwrap();
10031        let feed = store::upsert_feed(
10032            &state.db,
10033            &store::NewFeed {
10034                url: "https://list.example/feed.xml".to_string(),
10035                title: Some("List".to_string()),
10036                ..Default::default()
10037            },
10038        )
10039        .await
10040        .unwrap();
10041        store::insert_entries(
10042            &state.db,
10043            feed,
10044            &[store::NewEntry {
10045                guid: "l-1".to_string(),
10046                url: Some("https://list.example/1".to_string()),
10047                title: Some("Article".to_string()),
10048                published: Some("2026-07-11T00:00:00Z".to_string()),
10049                ..Default::default()
10050            }],
10051            0,
10052        )
10053        .await
10054        .unwrap();
10055        store::replace_sub_refs(&state.db, did, &[feed])
10056            .await
10057            .unwrap();
10058        let entry_id = store::entries_for_feed(&state.db, did, feed).await.unwrap()[0].id;
10059
10060        let cookie = session_cookie(&state, did, None);
10061        let app = router(state.clone());
10062
10063        let resp = app
10064            .oneshot(
10065                Request::builder()
10066                    .method("POST")
10067                    .uri(format!("/entries/{entry_id}/read"))
10068                    .header(header::COOKIE, cookie)
10069                    .header("HX-Request", "true")
10070                    .header("content-type", "application/x-www-form-urlencoded")
10071                    .body(Body::from("read=true"))
10072                    .unwrap(),
10073            )
10074            .await
10075            .unwrap();
10076        assert_eq!(resp.status(), StatusCode::OK);
10077        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
10078            .await
10079            .unwrap();
10080        let html = String::from_utf8(bytes.to_vec()).unwrap();
10081        assert!(
10082            !html.contains("hx-swap-oob"),
10083            "list-view response must NOT be an OOB swap: {html}"
10084        );
10085        // **And it must actually BE the row.** The assertion above is satisfied
10086        // by an empty body, or by any response that simply omits the attribute —
10087        // so on its own it pins half a property and the name promises the other
10088        // half.
10089        assert!(
10090            html.contains(&format!("/entries/{entry_id}")),
10091            "the response is not the row for this entry: {html}",
10092        );
10093        assert!(
10094            html.contains("Article"),
10095            "the row rendered without its title: {html}",
10096        );
10097        // **The row comes back carrying read state. That is all this proves.**
10098        //
10099        // It does NOT prove the state was persisted: the handler renders
10100        // `Some(read)` from the form value, so making `mark_read` roll back
10101        // instead of commit fails 11 store tests and leaves this one green.
10102        //
10103        // It does not prove the OVERRIDE either, which an earlier version of
10104        // this comment claimed. Verified: changing the call site to
10105        // `build_entry_row(pool, &did, id, None)` — deleting the override
10106        // wholesale — keeps the whole suite green, because `mark_read` has
10107        // already persisted the same value two lines earlier, so reading it back
10108        // from the database produces an identical row.
10109        //
10110        // Distinguishing the two needs a case where the override and the stored
10111        // state DISAGREE, which this handler never produces: it writes the value
10112        // it then renders. Left as a known gap rather than described as covered.
10113        assert!(
10114            html.contains("is-read"),
10115            "the row came back without the read state it was just given: {html}",
10116        );
10117    }
10118
10119    // -----------------------------------------------------------------------
10120    // Rename parity (POST /subscriptions/{rkey}/rename)
10121    // -----------------------------------------------------------------------
10122
10123    /// **Autodiscovery cannot smuggle a non-http(s) URL into storage.**
10124    ///
10125    /// The add path gates the URL the user *typed*; the URL it *stores* is
10126    /// whatever `resolve_feed_url` returns, which for an HTML page is a
10127    /// publisher-controlled `<link rel="alternate">` href. Two layers stop
10128    /// that: `discover_feed` yields only http(s), and the add path re-checks
10129    /// storability on the resolved URL. This test pins the DISJUNCTION —
10130    /// each layer alone holds it, both removed fails it — driven through the
10131    /// real route against a real local server.
10132    ///
10133    /// **Why the fixture is `ftp://`, not `at://`.** This began as the
10134    /// at-URI bypass test from #164, and it was vacuous twice over. Handle
10135    /// form: once storage became DID-only the privacy classifier refused it
10136    /// at its own gate. DID form: `Url::parse` cannot read it (invalid port
10137    /// — the colons in the DID), so `discover_feed` drops it before either
10138    /// layer exists. An at:// link cannot come out of autodiscovery under
10139    /// ANY mutation of the layers, so no test through this route can pin
10140    /// them with one. `ftp://` reaches both. The at:// case is guaranteed by
10141    /// structure and pinned where it lives: `discover_skips_a_non_http_
10142    /// alternate` and the storability tests in `feed.rs`.
10143    #[tokio::test]
10144    async fn autodiscovery_cannot_smuggle_a_non_http_url_into_storage() {
10145        let did = "did:plc:autodiscovered";
10146        // Access granted, both caps disabled — the only gates left are the
10147        // two under test.
10148        let state = test_state_with_caps(did, 0, 0).await;
10149
10150        let page = r#"<!doctype html><html><head><title>Blog</title>
10151            <link rel="alternate" type="application/rss+xml" href="ftp://files.example/feed.xml">
10152            </head><body>hi</body></html>"#;
10153        let base = crate::net::tests::serve_body(page.as_bytes().to_vec()).await;
10154        let port: u16 = base
10155            .trim_end_matches('/')
10156            .rsplit(':')
10157            .next()
10158            .unwrap()
10159            .parse()
10160            .unwrap();
10161        crate::net::test_host_override(
10162            "autodiscover-ftp.test",
10163            std::net::SocketAddr::from(([127, 0, 0, 1], port)),
10164        );
10165
10166        let cookie = session_cookie(&state, did, None);
10167        let resp = router(state.clone())
10168            .oneshot(
10169                Request::builder()
10170                    .method("POST")
10171                    .uri("/subscriptions")
10172                    .header(header::COOKIE, cookie)
10173                    .header("content-type", "application/x-www-form-urlencoded")
10174                    .body(Body::from(format!(
10175                        "url=http://autodiscover-ftp.test:{port}/"
10176                    )))
10177                    .unwrap(),
10178            )
10179            .await
10180            .unwrap();
10181        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10182        let loc = resp
10183            .headers()
10184            .get(header::LOCATION)
10185            .unwrap()
10186            .to_str()
10187            .unwrap();
10188        assert_ne!(loc, "/login", "the test never reached the add path");
10189        assert_ne!(loc, "/", "the subscribe succeeded");
10190
10191        assert_eq!(
10192            store::count_feeds(&state.db).await.unwrap(),
10193            0,
10194            "a non-http(s) URL from autodiscovery was stored"
10195        );
10196        assert_eq!(
10197            store::count_subscriptions_for_did(&state.db, did)
10198                .await
10199                .unwrap(),
10200            0
10201        );
10202    }
10203
10204    /// A rename that points at a BRAND-NEW feed URL while the shared cache is at
10205    /// its global ceiling must be refused (capacity flash) and must NOT insert a
10206    /// new `feeds` row — parity with add_subscription's global-cap guard, so a
10207    /// rename loop can't inflate the shared cache past the cap.
10208    #[tokio::test]
10209    async fn rename_to_new_url_refused_at_global_feeds_cap() {
10210        let did = "did:plc:renamer4";
10211        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
10212        // Global cap 1; pre-fill it with one feed so headroom is 0.
10213        let state = test_state_with_sidecar_and(&[did], &sidecar, false, 1).await;
10214        store::upsert_feed(
10215            &state.db,
10216            &store::NewFeed {
10217                url: "https://existing.example/feed.xml".to_string(),
10218                ..Default::default()
10219            },
10220        )
10221        .await
10222        .unwrap();
10223        let before = store::count_feeds(&state.db).await.unwrap();
10224        assert_eq!(before, 1);
10225
10226        let cookie = session_cookie(&state, did, None);
10227        let resp = router(state.clone())
10228            .oneshot(
10229                Request::builder()
10230                    .method("POST")
10231                    .uri("/subscriptions/rk-keep/rename")
10232                    .header(header::COOKIE, cookie)
10233                    .header("content-type", "application/x-www-form-urlencoded")
10234                    // A URL not in the cache → would be a NEW feeds row.
10235                    .body(Body::from(
10236                        "url=https://brand-new.example/feed.xml&title=Renamed",
10237                    ))
10238                    .unwrap(),
10239            )
10240            .await
10241            .unwrap();
10242        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10243        let loc = resp
10244            .headers()
10245            .get(header::LOCATION)
10246            .unwrap()
10247            .to_str()
10248            .unwrap();
10249        assert!(
10250            loc.contains("feed%20capacity"),
10251            "expected the feed-capacity flash, got {loc}"
10252        );
10253        // No new feeds row was inserted, and nothing reached the PDS.
10254        assert_eq!(store::count_feeds(&state.db).await.unwrap(), before);
10255        assert!(
10256            puts.lock().unwrap().is_empty(),
10257            "a refused repoint reached the PDS"
10258        );
10259    }
10260
10261    /// A repoint to an EXISTING URL adds no row, so it is allowed even at the
10262    /// global cap (only new URLs are gated) — the other half of the guard.
10263    ///
10264    /// On the sidecar fake, so "allowed" means the put actually happened: the
10265    /// earlier harness had no sidecar, and this passed on a "could not reach
10266    /// your PDS" flash that merely was not the capacity one.
10267    #[tokio::test]
10268    async fn rename_to_existing_url_allowed_at_global_feeds_cap() {
10269        let did = "did:plc:renamer4";
10270        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
10271        let state = test_state_with_sidecar_and(&[did], &sidecar, false, 1).await;
10272        store::upsert_feed(
10273            &state.db,
10274            &store::NewFeed {
10275                url: "https://existing.example/feed.xml".to_string(),
10276                ..Default::default()
10277            },
10278        )
10279        .await
10280        .unwrap();
10281        let before = store::count_feeds(&state.db).await.unwrap();
10282
10283        let cookie = session_cookie(&state, did, None);
10284        let resp = router(state.clone())
10285            .oneshot(
10286                Request::builder()
10287                    .method("POST")
10288                    .uri("/subscriptions/rk-keep/rename")
10289                    .header(header::COOKIE, cookie)
10290                    .header("content-type", "application/x-www-form-urlencoded")
10291                    .body(Body::from(
10292                        "url=https://existing.example/feed.xml&title=Retitled",
10293                    ))
10294                    .unwrap(),
10295            )
10296            .await
10297            .unwrap();
10298        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10299        let loc = resp
10300            .headers()
10301            .get(header::LOCATION)
10302            .unwrap()
10303            .to_str()
10304            .unwrap();
10305        assert_eq!(loc, "/", "the repoint to a cached URL was refused: {loc}");
10306        assert_eq!(
10307            puts.lock().unwrap().len(),
10308            1,
10309            "the repoint did not reach the PDS"
10310        );
10311        assert_eq!(store::count_feeds(&state.db).await.unwrap(), before);
10312    }
10313
10314    /// A rename with a blank URL writes nothing anywhere.
10315    #[tokio::test]
10316    async fn rename_with_blank_url_writes_nothing() {
10317        let did = "did:plc:renamer3";
10318        let state = test_state_with_caps(did, 0, 0).await;
10319        let before = store::count_feeds(&state.db).await.unwrap();
10320        assert_eq!(before, 0);
10321
10322        let cookie = session_cookie(&state, did, None);
10323        let app = router(state.clone());
10324        let resp = app
10325            .oneshot(
10326                Request::builder()
10327                    .method("POST")
10328                    .uri("/subscriptions/rkey123/rename")
10329                    .header(header::COOKIE, cookie)
10330                    .header("content-type", "application/x-www-form-urlencoded")
10331                    // Whitespace-only URL trims to empty.
10332                    .body(Body::from("url=%20%20&title=Nope"))
10333                    .unwrap(),
10334            )
10335            .await
10336            .unwrap();
10337        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10338        assert_eq!(
10339            resp.headers()
10340                .get(header::LOCATION)
10341                .unwrap()
10342                .to_str()
10343                .unwrap(),
10344            "/",
10345        );
10346        // Nothing was cached.
10347        assert_eq!(
10348            store::count_feeds(&state.db).await.unwrap(),
10349            0,
10350            "blank-URL rename wrote a junk feeds row"
10351        );
10352    }
10353
10354    /// A sidecar mock that serves ONE existing subscription record and captures
10355    /// every `put` body a rename produces.
10356    ///
10357    /// **Reads to `content-length` rather than taking one `read`.** A single
10358    /// read gets whatever one segment carried; if the head and body land
10359    /// separately the capture holds no record and every field assertion below
10360    /// passes for the wrong reason. Each captured body must also mention the
10361    /// collection, so an empty capture fails loudly instead of quietly.
10362    async fn spawn_rename_sidecar(
10363        existing: serde_json::Value,
10364    ) -> (String, std::sync::Arc<std::sync::Mutex<Vec<String>>>) {
10365        use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
10366        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
10367        let addr = listener.local_addr().unwrap();
10368        let puts = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
10369        let sink = puts.clone();
10370        tokio::spawn(async move {
10371            loop {
10372                let Ok((mut sock, _)) = listener.accept().await else {
10373                    break;
10374                };
10375                let mut raw: Vec<u8> = Vec::new();
10376                let mut chunk = [0u8; 4096];
10377                let body_text = loop {
10378                    let Ok(n) = sock.read(&mut chunk).await else {
10379                        break String::new();
10380                    };
10381                    if n == 0 {
10382                        break String::from_utf8_lossy(&raw).to_string();
10383                    }
10384                    raw.extend_from_slice(&chunk[..n]);
10385                    let Some(split) = raw.windows(4).position(|w| w == b"\r\n\r\n") else {
10386                        continue;
10387                    };
10388                    let (head, body) = raw.split_at(split + 4);
10389                    let want = String::from_utf8_lossy(head).lines().find_map(|l| {
10390                        let (k, v) = l.split_once(':')?;
10391                        k.eq_ignore_ascii_case("content-length")
10392                            .then(|| v.trim().parse::<usize>().ok())?
10393                    });
10394                    if want.is_none_or(|want| body.len() >= want) {
10395                        break String::from_utf8_lossy(body).to_string();
10396                    }
10397                };
10398
10399                // `"action":"put"` is the rename write; anything else is the read.
10400                let is_put = body_text.contains("\"action\":\"put\"");
10401                let data = if is_put {
10402                    sink.lock().unwrap().push(body_text.clone());
10403                    serde_json::json!({
10404                        "uri": "at://did:plc:x/community.lexicon.rss.subscription/rk-keep",
10405                        "cid": "bafyreiafter"
10406                    })
10407                } else {
10408                    serde_json::json!({ "records": [existing.clone()] })
10409                };
10410                let body = serde_json::json!({ "ok": true, "data": data }).to_string();
10411                let resp = format!(
10412                    "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
10413                    body.len(),
10414                    body
10415                );
10416                let _ = sock.write_all(resp.as_bytes()).await;
10417                let _ = sock.flush().await;
10418            }
10419        });
10420        (format!("http://{addr}"), puts)
10421    }
10422
10423    /// The existing record a rename must not destroy.
10424    fn seeded_subscription() -> serde_json::Value {
10425        serde_json::json!({
10426            "uri": "at://did:plc:renamer4/community.lexicon.rss.subscription/rk-keep",
10427            "cid": "bafyreibefore",
10428            "value": {
10429                "$type": "community.lexicon.rss.subscription",
10430                "url": "https://example.com/feed.xml",
10431                "title": "Old title",
10432                "siteUrl": "https://example.com/blog",
10433                "fetchHint": "hourly",
10434                "private": false,
10435                "createdAt": "2024-03-01T00:00:00.000Z"
10436            }
10437        })
10438    }
10439
10440    /// An existing standard.site subscription, as the 19 in production are:
10441    /// written before this reader refused the scheme, still in the repo.
10442    fn seeded_at_uri_subscription() -> serde_json::Value {
10443        seeded_subscription_with_url(AT_URI_SUB)
10444    }
10445    /// An existing subscription record at `rk-keep` with the given URL.
10446    fn seeded_subscription_with_url(url: &str) -> serde_json::Value {
10447        serde_json::json!({
10448            "uri": "at://did:plc:renamer5/community.lexicon.rss.subscription/rk-keep",
10449            "cid": "bafyreibefore",
10450            "value": {
10451                "$type": "community.lexicon.rss.subscription",
10452                "url": url,
10453                "title": "Old title",
10454                "private": false,
10455                "createdAt": "2024-03-01T00:00:00.000Z"
10456            }
10457        })
10458    }
10459    const AT_URI_SUB: &str =
10460        "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lab2c4d5e6f7g8h";
10461    const AT_URI_SUB_ENC: &str =
10462        "at%3A%2F%2Fdid%3Aplc%3Aohutz6x5acjmpuulp3x7wxxc%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h";
10463
10464    /// **Retitling an existing `at://` subscription must work with the flag off.**
10465    ///
10466    /// The storability guard was placed before the repo lookup, so it refused
10467    /// any rename whose URL is an at-URI — including a pure title or folder
10468    /// change on a record that already exists. On main that rename succeeded;
10469    /// the 19 production records would have become un-editable. The flag gates
10470    /// what may be STORED in the cache, not whether a reader may edit their own
10471    /// record: the PDS write goes through, the cache row is simply not created.
10472    #[tokio::test]
10473    async fn retitling_an_existing_at_uri_subscription_survives_the_flag_being_off() {
10474        let did = "did:plc:renamer5";
10475        let (sidecar, puts) = spawn_rename_sidecar(seeded_at_uri_subscription()).await;
10476        let state = test_state_with_sidecar(&[did], &sidecar).await;
10477        assert!(
10478            !state.config.standard_site,
10479            "the flag must be off for this test"
10480        );
10481        let cookie = session_cookie(&state, did, None);
10482        let resp = router(state.clone())
10483            .oneshot(
10484                Request::builder()
10485                    .method("POST")
10486                    .uri("/subscriptions/rk-keep/rename")
10487                    .header(header::COOKIE, cookie)
10488                    .header("content-type", "application/x-www-form-urlencoded")
10489                    .body(Body::from(format!("url={AT_URI_SUB_ENC}&title=New+title")))
10490                    .unwrap(),
10491            )
10492            .await
10493            .unwrap();
10494        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10495        let loc = resp
10496            .headers()
10497            .get(header::LOCATION)
10498            .unwrap()
10499            .to_str()
10500            .unwrap();
10501        assert_eq!(loc, "/", "the retitle was refused: {loc}");
10502
10503        let bodies = puts.lock().unwrap().clone();
10504        assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
10505        let sent: serde_json::Value = serde_json::from_str(&bodies[0]).expect("put body is JSON");
10506        assert_eq!(
10507            sent["record"]["title"], "New title",
10508            "the rename did not apply"
10509        );
10510        assert_eq!(
10511            sent["record"]["url"], AT_URI_SUB,
10512            "the rename changed the URL"
10513        );
10514
10515        // The flag still means what it says for the CACHE: no at:// row.
10516        let cached: i64 = store::count_unpollable_feeds(&state.db).await.unwrap();
10517        assert_eq!(cached, 0, "a retitle stored an at:// row with the flag off");
10518    }
10519
10520    /// **Repointing a subscription AT an `at://` URI is still refused with the
10521    /// flag off** — the half of the guard that has to survive the fix above.
10522    /// Nothing reaches the PDS and nothing reaches the cache.
10523    #[tokio::test]
10524    async fn repointing_a_subscription_at_an_at_uri_is_refused_with_the_flag_off() {
10525        let did = "did:plc:renamer4";
10526        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
10527        let state = test_state_with_sidecar(&[did], &sidecar).await;
10528        let cookie = session_cookie(&state, did, None);
10529        let resp = router(state.clone())
10530            .oneshot(
10531                Request::builder()
10532                    .method("POST")
10533                    .uri("/subscriptions/rk-keep/rename")
10534                    .header(header::COOKIE, cookie)
10535                    .header("content-type", "application/x-www-form-urlencoded")
10536                    .body(Body::from(format!("url={AT_URI_SUB_ENC}&title=Moved")))
10537                    .unwrap(),
10538            )
10539            .await
10540            .unwrap();
10541        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10542        let loc = resp
10543            .headers()
10544            .get(header::LOCATION)
10545            .unwrap()
10546            .to_str()
10547            .unwrap();
10548        assert!(loc.contains("flash="), "the repoint was not refused: {loc}");
10549        assert!(
10550            !loc.contains("Private"),
10551            "a storability refusal was reported as a privacy one: {loc}"
10552        );
10553        assert!(
10554            puts.lock().unwrap().is_empty(),
10555            "the repoint reached the PDS"
10556        );
10557        let cached: i64 = store::count_unpollable_feeds(&state.db).await.unwrap();
10558        assert_eq!(cached, 0);
10559    }
10560
10561    /// Posts a retitle of `rk-keep` with its URL unchanged; returns the
10562    /// redirect location.
10563    async fn retitle_unchanged(state: &AppState, did: &str, url_enc: &str) -> String {
10564        let cookie = session_cookie(state, did, None);
10565        let resp = router(state.clone())
10566            .oneshot(
10567                Request::builder()
10568                    .method("POST")
10569                    .uri("/subscriptions/rk-keep/rename")
10570                    .header(header::COOKIE, cookie)
10571                    .header("content-type", "application/x-www-form-urlencoded")
10572                    .body(Body::from(format!("url={url_enc}&title=New+title")))
10573                    .unwrap(),
10574            )
10575            .await
10576            .unwrap();
10577        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10578        resp.headers()
10579            .get(header::LOCATION)
10580            .unwrap()
10581            .to_str()
10582            .unwrap()
10583            .to_string()
10584    }
10585
10586    /// **The privacy gate has the same ordering bug the storable gate had.**
10587    ///
10588    /// Another client can write a subscription whose URL is an at-URI that is
10589    /// not a well-formed publication URI at all — a feed generator, say. On
10590    /// main a retitle of it succeeded (the DID form fails `Url::parse`, which
10591    /// the classifier reads as `Public`). The narrowed at:// arm now fails
10592    /// closed as `Private` for it, and the gate ran before `url_changed` was
10593    /// known — so the record became un-editable, with a flash claiming it "was
10594    /// not saved or sent anywhere". Both gates now apply to a repoint only.
10595    #[tokio::test]
10596    async fn retitling_an_existing_at_uri_record_that_is_not_a_publication_survives() {
10597        let did = "did:plc:renamer5";
10598        let other = "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/app.bsky.feed.generator/whats-hot";
10599        let other_enc =
10600            "at%3A%2F%2Fdid%3Aplc%3Aohutz6x5acjmpuulp3x7wxxc%2Fapp.bsky.feed.generator%2Fwhats-hot";
10601        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription_with_url(other)).await;
10602        let state = test_state_with_sidecar(&[did], &sidecar).await;
10603        let loc = retitle_unchanged(&state, did, other_enc).await;
10604        assert_eq!(loc, "/", "the retitle was refused: {loc}");
10605        let bodies = puts.lock().unwrap().clone();
10606        assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
10607        let sent: serde_json::Value = serde_json::from_str(&bodies[0]).unwrap();
10608        assert_eq!(sent["record"]["title"], "New title");
10609        assert_eq!(sent["record"]["url"], other);
10610    }
10611
10612    /// **A repoint to a secret-bearing URL is still refused** — the half of
10613    /// the privacy gate that has to survive moving it behind `url_changed`.
10614    /// Found by mutation: with the gate deleted outright, nothing failed.
10615    #[tokio::test]
10616    async fn repointing_a_subscription_at_a_private_feed_is_refused() {
10617        let did = "did:plc:renamer4";
10618        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
10619        let state = test_state_with_sidecar(&[did], &sidecar).await;
10620        let cookie = session_cookie(&state, did, None);
10621        let resp = router(state.clone())
10622            .oneshot(
10623                Request::builder()
10624                    .method("POST")
10625                    .uri("/subscriptions/rk-keep/rename")
10626                    .header(header::COOKIE, cookie)
10627                    .header("content-type", "application/x-www-form-urlencoded")
10628                    .body(Body::from(
10629                        "url=https%3A%2F%2Fpaid.example%2Ffeed.xml%3Ftoken%3DZm9vYmFyc2VjcmV0dG9rZW4&title=Moved",
10630                    ))
10631                    .unwrap(),
10632            )
10633            .await
10634            .unwrap();
10635        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10636        let loc = resp
10637            .headers()
10638            .get(header::LOCATION)
10639            .unwrap()
10640            .to_str()
10641            .unwrap();
10642        assert!(
10643            loc.contains("Private"),
10644            "the private repoint was not refused: {loc}"
10645        );
10646        assert!(
10647            puts.lock().unwrap().is_empty(),
10648            "a secret-bearing URL reached the PDS"
10649        );
10650        // The repo's fixture token: opaque enough for the classifier, not a real
10651        // key shape (a Stripe-shaped fixture tripped the secret scanner — rightly).
10652        let leaked = "https://paid.example/feed.xml?token=Zm9vYmFyc2VjcmV0dG9rZW4";
10653        assert!(store::get_feed_by_url(&state.db, leaked)
10654            .await
10655            .unwrap()
10656            .is_none());
10657    }
10658
10659    /// **A retitle of a never-cached at:// subscription is not "at feed
10660    /// capacity".** The global-ceiling check keyed on "URL not in the cache",
10661    /// and an at:// record is never cached with the flag off — so at capacity,
10662    /// a pure retitle was refused for a row the handler would not insert. The
10663    /// check now runs once `url_changed` is known and only for a repoint.
10664    #[tokio::test]
10665    async fn retitling_an_uncached_at_uri_subscription_is_not_refused_at_feed_capacity() {
10666        let did = "did:plc:renamer5";
10667        let (sidecar, puts) = spawn_rename_sidecar(seeded_at_uri_subscription()).await;
10668        // Ceiling 1, and one real feed already fills it.
10669        let state = test_state_with_sidecar_and(&[did], &sidecar, false, 1).await;
10670        store::upsert_feed(
10671            &state.db,
10672            &store::NewFeed {
10673                url: "https://filler.example/feed.xml".to_string(),
10674                ..Default::default()
10675            },
10676        )
10677        .await
10678        .unwrap();
10679        let loc = retitle_unchanged(&state, did, AT_URI_SUB_ENC).await;
10680        assert_eq!(loc, "/", "the retitle was refused: {loc}");
10681        assert_eq!(
10682            puts.lock().unwrap().len(),
10683            1,
10684            "the retitle did not reach the PDS"
10685        );
10686        assert_eq!(
10687            store::count_feeds(&state.db).await.unwrap(),
10688            1,
10689            "a row was inserted"
10690        );
10691    }
10692
10693    /// POST `/subscriptions` with `url`, returning the redirect target.
10694    async fn subscribe(state: &AppState, did: &str, url_enc: &str) -> String {
10695        let cookie = session_cookie(state, did, None);
10696        let resp = router(state.clone())
10697            .oneshot(
10698                Request::builder()
10699                    .method("POST")
10700                    .uri("/subscriptions")
10701                    .header(header::COOKIE, cookie)
10702                    .header("content-type", "application/x-www-form-urlencoded")
10703                    .body(Body::from(format!("url={url_enc}")))
10704                    .unwrap(),
10705            )
10706            .await
10707            .unwrap();
10708        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10709        resp.headers()
10710            .get(header::LOCATION)
10711            .unwrap()
10712            .to_str()
10713            .unwrap()
10714            .to_string()
10715    }
10716
10717    /// A `com.atproto.identity.resolveHandle` that answers `did` for anything.
10718    async fn serve_resolver(did: &str) -> String {
10719        let base = crate::net::tests::serve_body(
10720            serde_json::json!({ "did": did }).to_string().into_bytes(),
10721        )
10722        .await;
10723        let port: u16 = base
10724            .trim_end_matches('/')
10725            .rsplit(':')
10726            .next()
10727            .unwrap()
10728            .parse()
10729            .unwrap();
10730        let host = format!("resolver-{port}.test");
10731        crate::net::test_host_override(&host, std::net::SocketAddr::from(([127, 0, 0, 1], port)));
10732        format!("http://{host}:{port}")
10733    }
10734
10735    fn with_config(mut state: AppState, f: impl FnOnce(&mut Config)) -> AppState {
10736        let mut config = (*state.config).clone();
10737        f(&mut config);
10738        state.config = std::sync::Arc::new(config);
10739        state
10740    }
10741
10742    /// **0.4.0 step 3: with the flag ON, a well-formed at:// paste is
10743    /// subscribed.** It was refused as unsupported while nothing could read a
10744    /// publication; the poller reads them now. Stored in DID form, as a
10745    /// `publication`, and written to the reader's PDS like any subscription.
10746    #[tokio::test]
10747    async fn a_well_formed_at_uri_paste_is_subscribed_with_the_flag_on() {
10748        let did = "did:plc:renamer5";
10749        let (sidecar, log) = spawn_logging_sidecar().await;
10750        let state = with_config(
10751            test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
10752            |c| {
10753                c.oauth.plc_directory = "http://plc.nowhere.invalid".into();
10754            },
10755        );
10756        let loc = subscribe(&state, did, AT_URI_SUB_ENC).await;
10757        assert_eq!(loc, "/", "the paste was refused: {loc}");
10758        let row = store::get_feed_by_url(&state.db, AT_URI_SUB)
10759            .await
10760            .unwrap()
10761            .expect("no feed row");
10762        assert_eq!(feed::FeedKind::of(&row.url), feed::FeedKind::Publication);
10763        let sent = log.lock().unwrap().join("\n");
10764        assert!(
10765            sent.contains(AT_URI_SUB),
10766            "the subscription was not written to the PDS: {sent}"
10767        );
10768    }
10769
10770    /// **A0 through the form** — the 0.4.0 exit test, end to end: a reader
10771    /// pastes a publication, it is stored and written to their PDS, and the
10772    /// first poll — the one subscribing runs at once — stores its documents.
10773    #[tokio::test]
10774    async fn a0_subscribing_from_the_form_delivers_entries() {
10775        let did = "did:plc:renamer5";
10776        let author = "did:plc:ohutz6x5acjmpuulp3x7wxxc";
10777        let site = AT_URI_SUB;
10778        let (plc, _) = crate::standard_site::tests::serve_repo(
10779            author,
10780            vec![
10781                (
10782                    lexicon::nsid::STANDARD_PUBLICATION,
10783                    "3lab2c4d5e6f7g8h",
10784                    serde_json::json!({ "name": "A0 Journal", "url": "https://a0.example" }),
10785                ),
10786                (
10787                    lexicon::nsid::STANDARD_DOCUMENT,
10788                    "3l2a0frmaaa2a",
10789                    serde_json::json!({ "title": "From the form", "path": "/f",
10790                        "publishedAt": "2026-07-11T00:00:00Z", "site": site }),
10791                ),
10792            ],
10793        )
10794        .await;
10795        let (sidecar, _log) = spawn_logging_sidecar().await;
10796        let state = with_config(
10797            test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
10798            |c| {
10799                c.oauth.plc_directory = plc;
10800            },
10801        );
10802        assert_eq!(subscribe(&state, did, AT_URI_SUB_ENC).await, "/");
10803        let row = store::get_feed_by_url(&state.db, site)
10804            .await
10805            .unwrap()
10806            .unwrap();
10807        let titles: Vec<String> = sqlx::query_scalar("SELECT title FROM entries WHERE feed_id = ?")
10808            .bind(row.id)
10809            .fetch_all(&state.db)
10810            .await
10811            .unwrap();
10812        assert_eq!(
10813            titles,
10814            vec!["From the form".to_string()],
10815            "the first poll stored nothing"
10816        );
10817        assert_eq!(row.title.as_deref(), Some("A0 Journal"));
10818    }
10819
10820    /// A handle-form paste is resolved to the DID before it is stored: a
10821    /// handle is a mutable name, and `feeds.url` is keyed on identity.
10822    #[tokio::test]
10823    async fn a_handle_form_paste_is_stored_by_its_did() {
10824        let did = "did:plc:renamer5";
10825        let author = "did:plc:ohutz6x5acjmpuulp3x7wxxc";
10826        let (sidecar, _log) = spawn_logging_sidecar().await;
10827        let resolver = serve_resolver(author).await;
10828        let state = with_config(
10829            test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
10830            |c| {
10831                c.resolver_base = resolver;
10832                c.oauth.plc_directory = "http://plc.nowhere.invalid".into();
10833            },
10834        );
10835        let loc = subscribe(
10836            &state,
10837            did,
10838            "at%3A%2F%2Falice.example.com%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
10839        )
10840        .await;
10841        assert_eq!(loc, "/", "the paste was refused: {loc}");
10842        assert!(
10843            store::get_feed_by_url(&state.db, AT_URI_SUB)
10844                .await
10845                .unwrap()
10846                .is_some(),
10847            "not stored by its DID"
10848        );
10849        assert_eq!(
10850            store::count_feeds(&state.db).await.unwrap(),
10851            1,
10852            "the handle form was stored too"
10853        );
10854    }
10855
10856    /// A resolver answering `did` that counts how often it was asked.
10857    async fn serve_counting_resolver(
10858        did: &str,
10859    ) -> (String, std::sync::Arc<std::sync::atomic::AtomicUsize>) {
10860        let (base, hits) = crate::net::tests::serve_body_counted(
10861            serde_json::json!({ "did": did }).to_string().into_bytes(),
10862        )
10863        .await;
10864        let port: u16 = base
10865            .trim_end_matches('/')
10866            .rsplit(':')
10867            .next()
10868            .unwrap()
10869            .parse()
10870            .unwrap();
10871        let host = format!("counting-resolver-{port}.test");
10872        crate::net::test_host_override(&host, std::net::SocketAddr::from(([127, 0, 0, 1], port)));
10873        (format!("http://{host}:{port}"), hits)
10874    }
10875
10876    /// Review of #230: the per-DID cap is documented as checked "BEFORE any
10877    /// fetch/resolve so an over-cap account can't even trigger an outbound
10878    /// request" — a handle paste resolved the handle first.
10879    #[tokio::test]
10880    async fn an_over_cap_handle_paste_makes_no_outbound_request() {
10881        let did = "did:plc:renamer5";
10882        let (sidecar, _log) = spawn_logging_sidecar().await;
10883        let (resolver, hits) = serve_counting_resolver("did:plc:ohutz6x5acjmpuulp3x7wxxc").await;
10884        let state = with_config(
10885            test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
10886            |c| {
10887                c.resolver_base = resolver;
10888                c.max_subs_per_did = 1;
10889            },
10890        );
10891        let feed_id = store::upsert_feed(
10892            &state.db,
10893            &store::NewFeed {
10894                url: "https://already.example/feed.xml".into(),
10895                ..Default::default()
10896            },
10897        )
10898        .await
10899        .unwrap();
10900        store::replace_sub_refs(&state.db, did, &[feed_id])
10901            .await
10902            .unwrap();
10903        let loc = subscribe(
10904            &state,
10905            did,
10906            "at%3A%2F%2Falice.example.com%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
10907        )
10908        .await;
10909        assert!(
10910            loc.contains("Subscription%20limit"),
10911            "expected the cap flash: {loc}"
10912        );
10913        assert_eq!(
10914            hits.load(std::sync::atomic::Ordering::SeqCst),
10915            0,
10916            "an over-cap paste resolved a handle"
10917        );
10918    }
10919
10920    /// Review of #230: an authority that is neither a valid DID nor a valid
10921    /// handle — `did:plc:TOOSHORT`, an uppercase DID — went to the resolver as
10922    /// a "handle". It is unsupported, and asks nobody anything.
10923    #[tokio::test]
10924    async fn a_malformed_did_paste_is_unsupported_with_the_flag_on() {
10925        let did = "did:plc:renamer5";
10926        let (sidecar, _log) = spawn_logging_sidecar().await;
10927        let (resolver, hits) = serve_counting_resolver("did:plc:ohutz6x5acjmpuulp3x7wxxc").await;
10928        let state = with_config(
10929            test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
10930            |c| {
10931                c.resolver_base = resolver;
10932            },
10933        );
10934        for authority in [
10935            "did%3Aplc%3ATOOSHORT",
10936            "did%3Aplc%3AOHUTZ6X5ACJMPUULP3X7WXXC",
10937            "bad%0Ahandle.example",
10938        ] {
10939            let loc = subscribe(
10940                &state,
10941                did,
10942                &format!("at%3A%2F%2F{authority}%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h"),
10943            )
10944            .await;
10945            assert!(
10946                loc.contains("kind%20of%20feed"),
10947                "{authority}: expected the unsupported flash: {loc}"
10948            );
10949        }
10950        assert_eq!(
10951            hits.load(std::sync::atomic::Ordering::SeqCst),
10952            0,
10953            "a malformed authority reached the resolver"
10954        );
10955        assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
10956    }
10957
10958    /// A handle that does not resolve is refused, and nothing is stored.
10959    #[tokio::test]
10960    async fn an_unresolvable_handle_paste_is_refused() {
10961        let did = "did:plc:renamer5";
10962        let (sidecar, _log) = spawn_logging_sidecar().await;
10963        let state = with_config(
10964            test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
10965            |c| {
10966                c.resolver_base = "http://resolver.nowhere.invalid".into();
10967            },
10968        );
10969        let loc = subscribe(
10970            &state,
10971            did,
10972            "at%3A%2F%2Fnobody.example.com%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
10973        )
10974        .await;
10975        assert!(
10976            loc.contains("resolve%20the%20handle"),
10977            "expected the unresolvable-handle flash: {loc}"
10978        );
10979        assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
10980    }
10981
10982    /// An at:// URI that is not a publication is refused, flag on or off.
10983    #[tokio::test]
10984    async fn a_non_publication_at_uri_paste_is_refused() {
10985        let did = "did:plc:renamer5";
10986        let (sidecar, _log) = spawn_logging_sidecar().await;
10987        let state = test_state_with_sidecar_and(&[did], &sidecar, true, 0).await;
10988        let loc = subscribe(
10989            &state,
10990            did,
10991            "at%3A%2F%2Fdid%3Aplc%3Aohutz6x5acjmpuulp3x7wxxc%2Fapp.bsky.feed.post%2F3lab2c4d5e6f7g8h",
10992        )
10993        .await;
10994        assert!(
10995            loc.contains("kind%20of%20feed"),
10996            "expected the unsupported flash: {loc}"
10997        );
10998        assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
10999    }
11000
11001    /// A mixed-case scheme is canonicalised at input, not refused and not
11002    /// stored as a second spelling of the same publication.
11003    #[tokio::test]
11004    async fn a_mixed_case_at_scheme_paste_is_stored_canonically() {
11005        let did = "did:plc:renamer5";
11006        let (sidecar, _log) = spawn_logging_sidecar().await;
11007        let state = with_config(
11008            test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
11009            |c| {
11010                c.oauth.plc_directory = "http://plc.nowhere.invalid".into();
11011            },
11012        );
11013        let loc = subscribe(&state, did, &AT_URI_SUB_ENC.replacen("at", "At", 1)).await;
11014        assert_eq!(loc, "/", "the paste was refused: {loc}");
11015        assert!(store::get_feed_by_url(&state.db, AT_URI_SUB)
11016            .await
11017            .unwrap()
11018            .is_some());
11019    }
11020
11021    /// **With the flag ON, an OPML at:// entry is stored.** The one storage
11022    /// path that is meant to work today, asserted with the flag actually on.
11023    #[tokio::test]
11024    async fn opml_import_stores_an_at_uri_entry_with_the_flag_on() {
11025        let did = "did:plc:renamer5";
11026        let (sidecar, _puts) = spawn_rename_sidecar(seeded_subscription()).await;
11027        let state = test_state_with_sidecar_and(&[did], &sidecar, true, 0).await;
11028        let opml = format!(
11029            "<?xml version=\"1.0\"?>\n<opml version=\"2.0\"><head><title>t</title></head><body>\n\
11030             <outline type=\"rss\" text=\"Real\" xmlUrl=\"https://real.example/feed.xml\"/>\n\
11031             <outline type=\"rss\" text=\"Pub\" xmlUrl=\"{AT_URI_SUB}\"/>\n\
11032             </body></opml>"
11033        );
11034        let (ct, body) = opml_multipart(opml.as_bytes());
11035        let cookie = session_cookie(&state, did, None);
11036        let resp = router(state.clone())
11037            .oneshot(
11038                Request::builder()
11039                    .method("POST")
11040                    .uri("/opml")
11041                    .header(header::COOKIE, cookie)
11042                    .header("content-type", ct)
11043                    .body(Body::from(body))
11044                    .unwrap(),
11045            )
11046            .await
11047            .unwrap();
11048        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11049        let loc = resp
11050            .headers()
11051            .get(header::LOCATION)
11052            .unwrap()
11053            .to_str()
11054            .unwrap();
11055        assert!(
11056            loc.contains("Imported%202%20feeds"),
11057            "unexpected flash: {loc}"
11058        );
11059        assert!(
11060            !loc.contains("skipped"),
11061            "the at:// entry was skipped with the flag on: {loc}"
11062        );
11063        let stored = store::get_feed_by_url(&state.db, AT_URI_SUB).await.unwrap();
11064        assert!(
11065            stored.is_some(),
11066            "the at:// entry was not stored with the flag on"
11067        );
11068    }
11069
11070    /// **A retitle must not cache a secret-bearing URL.** Moving the privacy
11071    /// gate behind `url_changed` was right for the PDS write — the record is
11072    /// the reader's — but the cache write was gated only on `storable`, which
11073    /// any http(s) URL is. So a retitle of a record another client wrote with
11074    /// a tokened feed URL inserted that URL into the shared `feeds` table,
11075    /// where the poller would fail it every cycle and print it on the admin
11076    /// page. main refused the whole rename; this keeps the record editable and
11077    /// the cache clean, as `resolve_subscriptions` already does for the same
11078    /// record.
11079    #[tokio::test]
11080    async fn retitling_a_secret_bearing_record_does_not_cache_its_url() {
11081        let did = "did:plc:renamer5";
11082        let tokened = "https://www.patreon.com/rss/author?auth=Zm9vYmFyc2VjcmV0dG9rZW4";
11083        let tokened_enc =
11084            "https%3A%2F%2Fwww.patreon.com%2Frss%2Fauthor%3Fauth%3DZm9vYmFyc2VjcmV0dG9rZW4";
11085        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription_with_url(tokened)).await;
11086        let state = test_state_with_sidecar(&[did], &sidecar).await;
11087        let loc = retitle_unchanged(&state, did, tokened_enc).await;
11088        assert_eq!(loc, "/", "the retitle was refused: {loc}");
11089        assert_eq!(
11090            puts.lock().unwrap().len(),
11091            1,
11092            "the retitle did not reach the PDS"
11093        );
11094        assert!(
11095            store::get_feed_by_url(&state.db, tokened)
11096                .await
11097                .unwrap()
11098                .is_none(),
11099            "a secret-bearing URL was written to the shared cache by a retitle"
11100        );
11101    }
11102
11103    /// **On a repoint, storability is decided before privacy and capacity** —
11104    /// the same ordering the add path got. A malformed at:// target drew the
11105    /// private/paid flash, and at capacity a well-formed one drew "try again
11106    /// later" for a URL that can never be accepted with the flag off.
11107    #[tokio::test]
11108    async fn repointing_at_a_malformed_at_uri_is_refused_as_unsupported() {
11109        let did = "did:plc:renamer4";
11110        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
11111        let state = test_state_with_sidecar(&[did], &sidecar).await;
11112        let cookie = session_cookie(&state, did, None);
11113        let resp = router(state.clone())
11114            .oneshot(
11115                Request::builder()
11116                    .method("POST")
11117                    .uri("/subscriptions/rk-keep/rename")
11118                    .header(header::COOKIE, cookie)
11119                    .header("content-type", "application/x-www-form-urlencoded")
11120                    .body(Body::from(
11121                        "url=at%3A%2F%2Fdid%3Aplc%3ATOOSHORT%2Fsite.standard.publication%2F3lab&title=Moved",
11122                    ))
11123                    .unwrap(),
11124            )
11125            .await
11126            .unwrap();
11127        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11128        let loc = resp
11129            .headers()
11130            .get(header::LOCATION)
11131            .unwrap()
11132            .to_str()
11133            .unwrap();
11134        assert!(
11135            loc.contains("kind%20of%20feed"),
11136            "expected the unsupported flash: {loc}"
11137        );
11138        assert!(
11139            !loc.contains("Private"),
11140            "a typo was reported as a paid feed: {loc}"
11141        );
11142        assert!(puts.lock().unwrap().is_empty());
11143    }
11144
11145    #[tokio::test]
11146    async fn repointing_at_an_at_uri_at_capacity_is_refused_as_unsupported_not_capacity() {
11147        let did = "did:plc:renamer4";
11148        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
11149        let state = test_state_with_sidecar_and(&[did], &sidecar, false, 1).await;
11150        store::upsert_feed(
11151            &state.db,
11152            &store::NewFeed {
11153                url: "https://filler.example/feed.xml".to_string(),
11154                ..Default::default()
11155            },
11156        )
11157        .await
11158        .unwrap();
11159        let cookie = session_cookie(&state, did, None);
11160        let resp = router(state.clone())
11161            .oneshot(
11162                Request::builder()
11163                    .method("POST")
11164                    .uri("/subscriptions/rk-keep/rename")
11165                    .header(header::COOKIE, cookie)
11166                    .header("content-type", "application/x-www-form-urlencoded")
11167                    .body(Body::from(format!("url={AT_URI_SUB_ENC}&title=Moved")))
11168                    .unwrap(),
11169            )
11170            .await
11171            .unwrap();
11172        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11173        let loc = resp
11174            .headers()
11175            .get(header::LOCATION)
11176            .unwrap()
11177            .to_str()
11178            .unwrap();
11179        assert!(
11180            loc.contains("kind%20of%20feed"),
11181            "expected the unsupported flash: {loc}"
11182        );
11183        assert!(
11184            !loc.contains("capacity"),
11185            "an unacceptable URL was reported as a capacity problem: {loc}"
11186        );
11187        assert!(puts.lock().unwrap().is_empty());
11188    }
11189
11190    /// **`url_changed` compares like for like.** The form value is trimmed;
11191    /// the record's URL was compared raw, so a record another client wrote
11192    /// with a trailing space read as a repoint on every retitle and re-armed
11193    /// every gate — including the one that made an at:// record un-editable.
11194    #[tokio::test]
11195    async fn retitling_a_record_whose_url_carries_whitespace_is_not_a_repoint() {
11196        let did = "did:plc:renamer5";
11197        let padded = format!("{AT_URI_SUB} ");
11198        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription_with_url(&padded)).await;
11199        let state = test_state_with_sidecar(&[did], &sidecar).await;
11200        // The manage row posts the record's URL verbatim, padding included.
11201        let loc = retitle_unchanged(&state, did, &format!("{AT_URI_SUB_ENC}%20")).await;
11202        assert_eq!(
11203            loc, "/",
11204            "the retitle was treated as a repoint and refused: {loc}"
11205        );
11206        let bodies = puts.lock().unwrap().clone();
11207        assert_eq!(bodies.len(), 1);
11208        let sent: serde_json::Value = serde_json::from_str(&bodies[0]).unwrap();
11209        assert_eq!(
11210            sent["record"]["url"], AT_URI_SUB,
11211            "the padding was not normalised away"
11212        );
11213    }
11214
11215    /// **A retitle inserts no cache row.** The ceiling is checked on a repoint
11216    /// only, so the trailing upsert must not create a row for an unchanged URL
11217    /// that has none — with the flag on and the cache full, each retitle of a
11218    /// never-cached at:// record was a row past the cap. An existing row still
11219    /// gets its title kept in step.
11220    #[tokio::test]
11221    async fn retitling_an_uncached_record_at_capacity_inserts_no_row() {
11222        let did = "did:plc:renamer5";
11223        let (sidecar, puts) = spawn_rename_sidecar(seeded_at_uri_subscription()).await;
11224        let state = test_state_with_sidecar_and(&[did], &sidecar, true, 1).await;
11225        store::upsert_feed(
11226            &state.db,
11227            &store::NewFeed {
11228                url: "https://filler.example/feed.xml".to_string(),
11229                ..Default::default()
11230            },
11231        )
11232        .await
11233        .unwrap();
11234        let loc = retitle_unchanged(&state, did, AT_URI_SUB_ENC).await;
11235        assert_eq!(loc, "/", "the retitle was refused: {loc}");
11236        assert_eq!(puts.lock().unwrap().len(), 1);
11237        assert_eq!(
11238            store::count_feeds(&state.db).await.unwrap(),
11239            1,
11240            "a retitle inserted a cache row past the ceiling"
11241        );
11242    }
11243
11244    /// **The add path's at:// pre-check is about the MESSAGE, so it is
11245    /// case-insensitive.** `Url::parse` folds the scheme, so `AT://…` skipped
11246    /// the pre-check and the classifier's at:// arm alike, parsed as `at`, and
11247    /// tripped the secret heuristic on the rkey — the private/paid flash the
11248    /// pre-check exists to avoid. Storage stays case-sensitive; this does not
11249    /// touch it.
11250    #[tokio::test]
11251    async fn an_uppercase_at_scheme_paste_is_refused_as_unsupported() {
11252        let did = "did:plc:typoist";
11253        let state = test_state_with_caps(did, 0, 0).await;
11254        let cookie = session_cookie(&state, did, None);
11255        let resp = router(state.clone())
11256            .oneshot(
11257                Request::builder()
11258                    .method("POST")
11259                    .uri("/subscriptions")
11260                    .header(header::COOKIE, cookie)
11261                    .header("content-type", "application/x-www-form-urlencoded")
11262                    .body(Body::from(
11263                        "url=AT%3A%2F%2Falice.example.com%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
11264                    ))
11265                    .unwrap(),
11266            )
11267            .await
11268            .unwrap();
11269        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11270        let loc = resp
11271            .headers()
11272            .get(header::LOCATION)
11273            .unwrap()
11274            .to_str()
11275            .unwrap();
11276        assert!(
11277            loc.contains("kind%20of%20feed"),
11278            "expected the unsupported flash: {loc}"
11279        );
11280        assert!(!loc.contains("Private"), "reported as a paid feed: {loc}");
11281    }
11282
11283    /// **A rename must not destroy the fields the form never carries.**
11284    ///
11285    /// `update_subscription` is a `putRecord` — the WHOLE record is replaced, per
11286    /// its own doc. The handler built a fresh `Subscription::new(url, now())`, so
11287    /// every field absent from `templates/manage_row.html` (which posts only
11288    /// `url`, `title`, `folder`) was written back as its default:
11289    ///
11290    /// | field | before | after |
11291    /// |---|---|---|
11292    /// | `siteUrl` | whatever the feed advertised | gone |
11293    /// | `fetchHint` | as set | gone |
11294    /// | `private` | as set | gone |
11295    /// | `createdAt` | original subscribe time | reset to now |
11296    ///
11297    /// `createdAt` is the worst of the four: it is the sort key for "when did I
11298    /// subscribe", it is unrecoverable once overwritten, and nothing in the UI
11299    /// tells the reader it moved.
11300    ///
11301    /// Asserted on the BYTES THE SIDECAR RECEIVES, not on a `Subscription` built
11302    /// in the test — the record only becomes wrong on the way out, so checking
11303    /// the value we passed in would pass just as happily with the fix removed.
11304    #[tokio::test]
11305    async fn renaming_preserves_the_fields_the_form_never_carries() {
11306        let did = "did:plc:renamer4";
11307        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
11308        let state = test_state_with_sidecar(&[did], &sidecar).await;
11309        let cookie = session_cookie(&state, did, None);
11310
11311        let resp = router(state.clone())
11312            .oneshot(
11313                Request::builder()
11314                    .method("POST")
11315                    .uri("/subscriptions/rk-keep/rename")
11316                    .header(header::COOKIE, cookie)
11317                    .header("content-type", "application/x-www-form-urlencoded")
11318                    // Exactly what the manage row posts: url, title, folder.
11319                    .body(Body::from(
11320                        "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=New+title&folder=Tech",
11321                    ))
11322                    .unwrap(),
11323            )
11324            .await
11325            .unwrap();
11326        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11327
11328        let bodies = puts.lock().unwrap().clone();
11329        assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
11330        let body = &bodies[0];
11331        // Anchors the negative assertions: an empty capture would satisfy them.
11332        assert!(
11333            body.contains("community.lexicon.rss.subscription"),
11334            "captured no usable put body: {body:?}"
11335        );
11336
11337        let sent: serde_json::Value = serde_json::from_str(body).expect("put body is JSON");
11338        let record = &sent["record"];
11339
11340        // What the form DID carry must be applied.
11341        assert_eq!(record["title"], "New title", "the rename did not apply");
11342        assert_eq!(record["folder"], "Tech", "the re-folder did not apply");
11343
11344        // What the form did NOT carry must survive.
11345        assert_eq!(
11346            record["createdAt"], "2024-03-01T00:00:00.000Z",
11347            "the rename reset createdAt — the reader's subscribe time is gone \
11348             from their own repo, and nothing told them"
11349        );
11350        assert_eq!(
11351            record["siteUrl"], "https://example.com/blog",
11352            "the rename erased siteUrl"
11353        );
11354        assert_eq!(record["fetchHint"], "hourly", "the rename erased fetchHint");
11355        assert_eq!(record["private"], false, "the rename erased private");
11356    }
11357
11358    /// **Repointing at a different feed drops that feed's properties, but not
11359    /// the subscription's.**
11360    ///
11361    /// `siteUrl` and `fetchHint` describe the feed the subscription points at,
11362    /// so carrying them onto a different URL would leave a site link for the old
11363    /// feed hanging off the new one. `createdAt` and `private` are properties of
11364    /// the SUBSCRIPTION and survive a repoint — the reader subscribed when they
11365    /// subscribed, whatever the URL was later corrected to.
11366    #[tokio::test]
11367    async fn repointing_a_feed_drops_the_old_feeds_properties_but_keeps_the_subscriptions() {
11368        let did = "did:plc:renamer4";
11369        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
11370        let state = test_state_with_sidecar(&[did], &sidecar).await;
11371        let cookie = session_cookie(&state, did, None);
11372
11373        let resp = router(state.clone())
11374            .oneshot(
11375                Request::builder()
11376                    .method("POST")
11377                    .uri("/subscriptions/rk-keep/rename")
11378                    .header(header::COOKIE, cookie)
11379                    .header("content-type", "application/x-www-form-urlencoded")
11380                    // A DIFFERENT feed URL from the seeded record.
11381                    .body(Body::from(
11382                        "url=https%3A%2F%2Fother.example%2Ffeed.xml&title=Repointed",
11383                    ))
11384                    .unwrap(),
11385            )
11386            .await
11387            .unwrap();
11388        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11389
11390        let bodies = puts.lock().unwrap().clone();
11391        assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
11392        assert!(
11393            bodies[0].contains("community.lexicon.rss.subscription"),
11394            "captured no usable put body: {:?}",
11395            bodies[0]
11396        );
11397        let sent: serde_json::Value = serde_json::from_str(&bodies[0]).expect("put body is JSON");
11398        let record = &sent["record"];
11399
11400        assert_eq!(record["url"], "https://other.example/feed.xml");
11401        // The old feed's properties are gone rather than misattributed.
11402        assert!(
11403            record.get("siteUrl").is_none() || record["siteUrl"].is_null(),
11404            "the old feed's site link followed the subscription to a new feed: {record}"
11405        );
11406        assert!(
11407            record.get("fetchHint").is_none() || record["fetchHint"].is_null(),
11408            "the old feed's fetch hint followed the subscription to a new feed: {record}"
11409        );
11410        // The subscription's own properties survive.
11411        assert_eq!(
11412            record["createdAt"], "2024-03-01T00:00:00.000Z",
11413            "a repoint is still not a new subscription; createdAt must not move"
11414        );
11415        assert_eq!(record["private"], false, "the repoint erased private");
11416    }
11417
11418    /// **A rename against an rkey that is not in the repo writes NOTHING.**
11419    ///
11420    /// `update_subscription` is a `putRecord`, which CREATES the record when the
11421    /// rkey does not exist — with whatever `createdAt` we hand it. So without
11422    /// this refusal a rename against a stale or wrong rkey manufactures a
11423    /// subscription dated today, which is the bug this whole change exists to
11424    /// fix, arriving by a different door.
11425    ///
11426    /// The guard was untested when first written: removing it left all 733 tests
11427    /// green. An untested guard against the exact defect being fixed is how the
11428    /// two previous rounds of this problem got through.
11429    #[tokio::test]
11430    async fn renaming_an_unknown_rkey_writes_nothing() {
11431        let did = "did:plc:renamer4";
11432        // The sidecar serves exactly one record, at rkey `rk-keep`.
11433        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
11434        let state = test_state_with_sidecar(&[did], &sidecar).await;
11435        let cookie = session_cookie(&state, did, None);
11436
11437        let resp = router(state.clone())
11438            .oneshot(
11439                Request::builder()
11440                    .method("POST")
11441                    // ...and this is not it.
11442                    .uri("/subscriptions/rk-does-not-exist/rename")
11443                    .header(header::COOKIE, cookie)
11444                    .header("content-type", "application/x-www-form-urlencoded")
11445                    .body(Body::from(
11446                        "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=Ghost",
11447                    ))
11448                    .unwrap(),
11449            )
11450            .await
11451            .unwrap();
11452
11453        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11454        let loc = resp
11455            .headers()
11456            .get(header::LOCATION)
11457            .unwrap()
11458            .to_str()
11459            .unwrap();
11460        assert!(
11461            loc.contains("flash="),
11462            "an unknown rkey redirected as though the rename had worked: {loc}"
11463        );
11464        assert!(
11465            puts.lock().unwrap().is_empty(),
11466            "a rename against an unknown rkey wrote a record — putRecord would \
11467             CREATE it, dated today: {:?}",
11468            puts.lock().unwrap()
11469        );
11470    }
11471
11472    /// **A `site_url` the client actually sends is applied, not dropped.**
11473    ///
11474    /// `templates/manage_row.html` does not post this field, so it is tempting
11475    /// to read the arm that handles it as dead code. It is not:
11476    /// `RenameSubForm` carries `site_url`, so a hand-crafted POST reaches it
11477    /// today. Discarding the value instead of applying it left all 733 tests
11478    /// green.
11479    ///
11480    /// The value is scheme-checked on the way out by the repo-boundary vet, so
11481    /// this is a coverage gap rather than an exposure — but an untested path
11482    /// that writes a URL into the reader's PDS should not stay untested.
11483    #[tokio::test]
11484    async fn a_client_supplied_site_url_reaches_the_record() {
11485        let did = "did:plc:renamer4";
11486        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
11487        let state = test_state_with_sidecar(&[did], &sidecar).await;
11488        let cookie = session_cookie(&state, did, None);
11489
11490        let resp = router(state.clone())
11491            .oneshot(
11492                Request::builder()
11493                    .method("POST")
11494                    .uri("/subscriptions/rk-keep/rename")
11495                    .header(header::COOKIE, cookie)
11496                    .header("content-type", "application/x-www-form-urlencoded")
11497                    // Same feed URL, but carrying a site_url the manage row
11498                    // never sends.
11499                    .body(Body::from(
11500                        "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=Kept\
11501                         &site_url=https%3A%2F%2Ftyped.example%2Fsite",
11502                    ))
11503                    .unwrap(),
11504            )
11505            .await
11506            .unwrap();
11507        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11508
11509        let bodies = puts.lock().unwrap().clone();
11510        assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
11511        assert!(
11512            bodies[0].contains("community.lexicon.rss.subscription"),
11513            "captured no usable put body: {:?}",
11514            bodies[0]
11515        );
11516        let sent: serde_json::Value = serde_json::from_str(&bodies[0]).expect("put body is JSON");
11517        assert_eq!(
11518            sent["record"]["siteUrl"], "https://typed.example/site",
11519            "the client's siteUrl was dropped; the seeded record's survived instead"
11520        );
11521    }
11522
11523    /// **A rename whose read fails writes NOTHING.**
11524    ///
11525    /// This is the property most easily lost when someone later touches this
11526    /// handler: falling back to `Subscription::new` on a read error looks like
11527    /// graceful degradation and is in fact the original bug, reinstated on
11528    /// exactly the path where it is hardest to notice. The reader must be told
11529    /// instead.
11530    #[tokio::test]
11531    async fn a_rename_whose_read_fails_writes_nothing() {
11532        let did = "did:plc:renamer5";
11533        // A port that accepts nothing: the read cannot succeed.
11534        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
11535        let dead = format!("http://{}", listener.local_addr().unwrap());
11536        drop(listener);
11537
11538        let state = test_state_with_sidecar(&[did], &dead).await;
11539        let cookie = session_cookie(&state, did, None);
11540        let before = store::count_feeds(&state.db).await.unwrap();
11541
11542        let resp = router(state.clone())
11543            .oneshot(
11544                Request::builder()
11545                    .method("POST")
11546                    .uri("/subscriptions/rk-keep/rename")
11547                    .header(header::COOKIE, cookie)
11548                    .header("content-type", "application/x-www-form-urlencoded")
11549                    .body(Body::from(
11550                        "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=Doomed",
11551                    ))
11552                    .unwrap(),
11553            )
11554            .await
11555            .unwrap();
11556
11557        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
11558        let loc = resp
11559            .headers()
11560            .get(header::LOCATION)
11561            .unwrap()
11562            .to_str()
11563            .unwrap();
11564        assert!(
11565            loc.contains("flash="),
11566            "a failed read redirected as though the rename had worked: {loc}"
11567        );
11568        assert_eq!(
11569            store::count_feeds(&state.db).await.unwrap(),
11570            before,
11571            "a rename that could not read the record still wrote to the cache"
11572        );
11573    }
11574
11575    /// Folder pre-selection regression: the manage rename row must mark the
11576    /// feed's CURRENT folder `<option>` as `selected`, so an untouched Save
11577    /// re-submits the current folder instead of silently un-foldering the feed.
11578    /// A loose (un-foldered) feed must mark "No folder" selected instead. Renders
11579    /// `ManageTemplate` directly so no PDS/sidecar round-trip is needed.
11580    #[test]
11581    fn manage_rename_row_preselects_current_folder() {
11582        let nav = Nav {
11583            handle: "@reader.example".to_string(),
11584            avatar: "RE".to_string(),
11585            view: "unread".to_string(),
11586            scope_qs: String::new(),
11587            folders: Vec::new(),
11588            loose_feeds: Vec::new(),
11589            manage_active: true,
11590        };
11591        let folder_options = vec![
11592            FolderOption {
11593                uri: "at://did:plc:x/app.folder/work".to_string(),
11594                name: "Work".to_string(),
11595            },
11596            FolderOption {
11597                uri: "at://did:plc:x/app.folder/fun".to_string(),
11598                name: "Fun".to_string(),
11599            },
11600        ];
11601        // A foldered feed (in "Work") and a loose feed (no folder), each with a
11602        // non-empty rkey so the rename form renders.
11603        let foldered = FeedView {
11604            rkey: "sub-foldered".to_string(),
11605            url: "https://work.example/feed.xml".to_string(),
11606            title: "Work Feed".to_string(),
11607            unread: 0,
11608            selected: false,
11609            folder: Some("at://did:plc:x/app.folder/work".to_string()),
11610        };
11611        let loose = FeedView {
11612            rkey: "sub-loose".to_string(),
11613            url: "https://loose.example/feed.xml".to_string(),
11614            title: "Loose Feed".to_string(),
11615            unread: 0,
11616            selected: false,
11617            folder: None,
11618        };
11619        let tmpl = ManageTemplate {
11620            card: Card::private(&Config::default()),
11621            version: VERSION,
11622            repo_url: REPO_URL,
11623            kofi_url: KOFI_URL,
11624            flash: String::new(),
11625            alert: String::new(),
11626            nav,
11627            folder_options,
11628            folders: vec![FolderView {
11629                rkey: "folder-work".to_string(),
11630                uri: "at://did:plc:x/app.folder/work".to_string(),
11631                name: "Work".to_string(),
11632                feeds: vec![foldered],
11633                selected: false,
11634            }],
11635            loose_feeds: vec![loose],
11636            standard_site: false,
11637        };
11638        let html = tmpl.render().unwrap();
11639
11640        // The foldered feed's "Work" option is pre-selected.
11641        assert!(
11642            html.contains(
11643                r#"<option value="at://did:plc:x/app.folder/work" selected>Work</option>"#
11644            ),
11645            "foldered feed must pre-select its current folder: {html}"
11646        );
11647        // The loose feed's "No folder" option is pre-selected (appears for the
11648        // loose row, which has folder=None).
11649        assert!(
11650            html.contains(r#"<option value="" selected>No folder</option>"#),
11651            "loose feed must pre-select 'No folder': {html}"
11652        );
11653    }
11654
11655    /// **The public stats page carries no user data.**
11656    ///
11657    /// It is reachable by anyone, so the thing worth pinning is what it does
11658    /// NOT say: nothing about how many people use the instance, nothing about
11659    /// which feeds fail, nothing about who reads what.
11660    #[tokio::test]
11661    async fn the_public_stats_page_exposes_no_user_data() {
11662        let state = test_state(&[]).await;
11663        store::ensure_seed(&state.db, &["did:plc:someone".to_string()])
11664            .await
11665            .unwrap();
11666
11667        let resp = router(state)
11668            .oneshot(
11669                Request::builder()
11670                    .uri("/stats")
11671                    .body(Body::empty())
11672                    .unwrap(),
11673            )
11674            .await
11675            .unwrap();
11676        assert_eq!(resp.status(), StatusCode::OK, "stats must be public");
11677
11678        let body = String::from_utf8(
11679            axum::body::to_bytes(resp.into_body(), usize::MAX)
11680                .await
11681                .unwrap()
11682                .to_vec(),
11683        )
11684        .unwrap();
11685
11686        // Structural checks, not word checks. The page's own prose says it
11687        // publishes no error rates, so searching for that PHRASE finds the
11688        // disclaimer rather than a leak — the first version of this test failed
11689        // on exactly that. What matters is whether identifiers or the
11690        // admin-only figures are present.
11691        assert!(
11692            !body.contains("did:"),
11693            "the public stats page leaked an identifier"
11694        );
11695        for admin_only in ["errp50ms", "p95ms", "live backend", "ok_count"] {
11696            assert!(
11697                !body.contains(admin_only),
11698                "the public page is showing the admin metrics column {admin_only:?}"
11699            );
11700        }
11701        // And it does render the aggregate it exists for.
11702        assert!(body.contains("Feeds tracked"));
11703        assert!(body.contains("Waiting to be polled"));
11704    }
11705
11706    /// **The two states that stop feeds updating must be visible.**
11707    ///
11708    /// `overdue` and `polled_last_hour` move in BOTH and distinguish neither —
11709    /// and `overdue` moves the WRONG WAY for backoff, because backoff is applied
11710    /// by pushing `next_poll` forward, so a feed failing every fetch drops out of
11711    /// the backlog and makes the page read healthier. That inversion is what this
11712    /// test pins: a broken feed must raise a number, not lower one.
11713    #[tokio::test]
11714    async fn stats_distinguishes_backoff_from_a_watermark_pause() {
11715        let state = test_state(&[]).await;
11716        // Three feeds: one healthy, one flaky, one long dead.
11717        for (url, errors) in [
11718            ("https://ok.example/f.xml", 0),
11719            ("https://flaky.example/f.xml", 2),
11720            ("https://dead.example/f.xml", 9),
11721        ] {
11722            store::upsert_feed(
11723                &state.db,
11724                &store::NewFeed {
11725                    url: url.to_string(),
11726                    // Pushed forward, exactly as backoff does — so none of these
11727                    // are counted as `overdue`.
11728                    next_poll: Some("2099-01-01T00:00:00Z".to_string()),
11729                    ..Default::default()
11730                },
11731            )
11732            .await
11733            .unwrap();
11734            for _ in 0..errors {
11735                store::bump_feed_errors(
11736                    &state.db,
11737                    url,
11738                    feed::FailureKind::Fetch,
11739                    "connection refused",
11740                )
11741                .await
11742                .unwrap();
11743            }
11744        }
11745
11746        let render_stats = |state: AppState| async move {
11747            let resp = router(state)
11748                .oneshot(
11749                    Request::builder()
11750                        .uri("/stats")
11751                        .body(Body::empty())
11752                        .unwrap(),
11753                )
11754                .await
11755                .unwrap();
11756            assert_eq!(resp.status(), StatusCode::OK);
11757            String::from_utf8(
11758                axum::body::to_bytes(resp.into_body(), usize::MAX)
11759                    .await
11760                    .unwrap()
11761                    .to_vec(),
11762            )
11763            .unwrap()
11764        };
11765
11766        // **The fixture must actually be RUNNING, or this test measures nothing.**
11767        // `test_state` leaves `schedulers_enabled` false, and `fetching_state`
11768        // checks that BEFORE the watermark — so without these two lines every
11769        // render below reports "off" and the watermark can never surface. The
11770        // assertions still passed, for reasons unrelated to what they name: see
11771        // the two comments below.
11772        state.runtime_health.set_schedulers_enabled(true);
11773        state
11774            .runtime_health
11775            .poll_tick_completed(crate::store::now_unix());
11776
11777        let body = render_stats(state.clone()).await;
11778        assert!(
11779            body.contains("Failing"),
11780            "backoff is still invisible on the public page"
11781        );
11782        // 2 failing, 1 of them badly (>= BADLY_BROKEN_ERRORS). Matched on the
11783        // value rather than on surrounding whitespace, so re-indenting the
11784        // template cannot break this.
11785        assert!(
11786            body.contains("2, 1 badly"),
11787            "expected '2, 1 badly' in the failing row; got:\n{}",
11788            body.split("Failing")
11789                .nth(1)
11790                .unwrap_or("")
11791                .chars()
11792                .take(300)
11793                .collect::<String>()
11794        );
11795        // Not paused, and the backlog is genuinely empty — which is exactly the
11796        // reading that used to be indistinguishable from healthy.
11797        //
11798        // **Asserted by EXCLUDING the other states, not by matching "running".**
11799        // The `off` row reads "the poller is not running on this instance", which
11800        // contains "running" — so the bare substring passed while the page was
11801        // reporting the exact opposite of what this line claims to check.
11802        assert!(
11803            !body.contains("the poller is not running")
11804                && !body.contains("the cache is at its size limit")
11805                && !body.contains("has not completed a round"),
11806            "expected the running state; the page reported a stopped one",
11807        );
11808
11809        // Now trip the watermark. Nothing in the database changes; only the
11810        // recorded runtime state does — which is the whole reason it needed a
11811        // home outside the log stream.
11812        state.runtime_health.set_watermark(true);
11813        let paused = render_stats(state.clone()).await;
11814        // Matched on the paused row's OWN sentence. The bare word "paused" also
11815        // appeared in the page's explanatory prose, so this assertion passed
11816        // whether or not the row rendered — and trimming that prose is what
11817        // exposed it. This phrase exists only inside the `paused` branch.
11818        assert!(
11819            paused.contains("the cache is at its size limit"),
11820            "a watermark pause is still invisible on the public page"
11821        );
11822
11823        // Still no identifiers: these are counts, not feeds.
11824        for leak in ["ok.example", "flaky.example", "dead.example", "did:"] {
11825            assert!(
11826                !paused.contains(leak),
11827                "the public page leaked {leak:?} while reporting failures"
11828            );
11829        }
11830    }
11831
11832    /// **`/admin/metrics` is gated, and nothing checked that it was.**
11833    ///
11834    /// Deleting the `admin_seed_dids` check left the entire suite green. That
11835    /// was survivable while the page held only aggregate timings; it is not now,
11836    /// because this branch puts **per-feed URLs and remote error text** behind
11837    /// that gate. A guarantee nothing checks is a comment, and this one is now
11838    /// the only thing standing between a signed-in stranger and the operational
11839    /// picture the handler's own doc says is not public.
11840    ///
11841    /// All three doors: no session, a session that is not an admin, and the
11842    /// admin itself.
11843    #[tokio::test]
11844    async fn admin_metrics_is_refused_to_everyone_but_an_admin() {
11845        let admin = "did:plc:adminseed";
11846        // **Only the admin is in ALLOWED_DIDS**, because `admin_seed_dids()`
11847        // IS that list — deliberately, per its doc: "the same people I trust on
11848        // this instance". Production sets it to the bootstrap DID alone.
11849        //
11850        // A genuine non-admin is therefore someone holding a beta seat granted
11851        // by an invite, not by the allow-list. Seeding both would have made
11852        // both admins and quietly turned the 403 assertion below into a test of
11853        // nothing — which is exactly what the first draft of this did.
11854        let state = test_state(&[admin]).await;
11855        store::grant_access(&state.db, "did:plc:ordinaryuser", None, "invite", None)
11856            .await
11857            .unwrap();
11858        let url = "https://broken.example/f.xml";
11859        store::upsert_feed(
11860            &state.db,
11861            &store::NewFeed {
11862                url: url.to_string(),
11863                ..Default::default()
11864            },
11865        )
11866        .await
11867        .unwrap();
11868        store::bump_feed_errors(
11869            &state.db,
11870            url,
11871            feed::FailureKind::Fetch,
11872            "SENTINEL_ADMIN_ONLY",
11873        )
11874        .await
11875        .unwrap();
11876
11877        let get = |state: AppState, cookie: Option<String>| async move {
11878            let mut req = Request::builder().uri("/admin/metrics");
11879            if let Some(c) = cookie {
11880                req = req.header(header::COOKIE, c);
11881            }
11882            let resp = router(state)
11883                .oneshot(req.body(Body::empty()).unwrap())
11884                .await
11885                .unwrap();
11886            let status = resp.status();
11887            let body = String::from_utf8(
11888                axum::body::to_bytes(resp.into_body(), usize::MAX)
11889                    .await
11890                    .unwrap()
11891                    .to_vec(),
11892            )
11893            .unwrap();
11894            (status, body)
11895        };
11896
11897        // No session at all.
11898        let (status, body) = get(state.clone(), None).await;
11899        assert_eq!(status, StatusCode::UNAUTHORIZED);
11900        assert!(
11901            !body.contains("SENTINEL_ADMIN_ONLY"),
11902            "leaked to anonymous: {body}"
11903        );
11904
11905        // A real, signed-in user who is not an admin.
11906        let ordinary = session_cookie(&state, "did:plc:ordinaryuser", None);
11907        let (status, body) = get(state.clone(), Some(ordinary)).await;
11908        assert_eq!(
11909            status,
11910            StatusCode::FORBIDDEN,
11911            "a non-admin session was let in"
11912        );
11913        assert!(
11914            !body.contains("SENTINEL_ADMIN_ONLY") && !body.contains("broken.example"),
11915            "leaked to a non-admin: {body}",
11916        );
11917
11918        // The admin does get it — otherwise the two refusals above are
11919        // satisfied by the endpoint being broken for everyone.
11920        let admin_cookie = session_cookie(&state, admin, None);
11921        let (status, body) = get(state, Some(admin_cookie)).await;
11922        assert_eq!(status, StatusCode::OK);
11923        assert!(
11924            body.contains("SENTINEL_ADMIN_ONLY"),
11925            "admin cannot see it: {body}"
11926        );
11927    }
11928
11929    /// **The cause a public count cannot carry belongs on the admin page.**
11930    ///
11931    /// The public histogram is four coarse buckets, and `fetch` is the coarsest:
11932    /// #159's own error — `guarded_get` bailing on a 304 — lands there beside
11933    /// DNS failure, timeout and SSRF refusal. So the histogram alone would NOT
11934    /// have separated "sixty dead publishers" from "one bug here", which is the
11935    /// case it was justified by.
11936    ///
11937    /// The answer is not a finer public vocabulary — `/stats` promises never
11938    /// which feed and never whose, and a bucket per error string would break
11939    /// that. It is to put the detail where per-feed data is already allowed.
11940    /// `/admin/metrics` is gated on `ALLOWED_DIDS` and already carries an
11941    /// operational picture.
11942    ///
11943    /// Asserts both halves: the detail IS on the admin page, and is NOT on the
11944    /// public one.
11945    #[tokio::test]
11946    async fn the_admin_page_names_failing_feeds_and_the_public_page_does_not() {
11947        let admin = "did:plc:adminseed";
11948        let state = test_state(&[admin]).await;
11949        let url = "https://broken.example/f.xml";
11950        store::upsert_feed(
11951            &state.db,
11952            &store::NewFeed {
11953                url: url.to_string(),
11954                ..Default::default()
11955            },
11956        )
11957        .await
11958        .unwrap();
11959        store::bump_feed_errors(
11960            &state.db,
11961            url,
11962            feed::FailureKind::Fetch,
11963            "SENTINEL_REDIRECT_NO_LOCATION",
11964        )
11965        .await
11966        .unwrap();
11967
11968        let cookie = session_cookie(&state, admin, None);
11969        let resp = router(state.clone())
11970            .oneshot(
11971                Request::builder()
11972                    .uri("/admin/metrics")
11973                    .header(header::COOKIE, cookie)
11974                    .body(Body::empty())
11975                    .unwrap(),
11976            )
11977            .await
11978            .unwrap();
11979        assert_eq!(resp.status(), StatusCode::OK);
11980        let admin_body = String::from_utf8(
11981            axum::body::to_bytes(resp.into_body(), usize::MAX)
11982                .await
11983                .unwrap()
11984                .to_vec(),
11985        )
11986        .unwrap();
11987        assert!(
11988            admin_body.contains("SENTINEL_REDIRECT_NO_LOCATION"),
11989            "the admin page does not carry the failure detail: {admin_body}",
11990        );
11991        assert!(
11992            admin_body.contains("broken.example"),
11993            "the admin page does not name the failing feed: {admin_body}",
11994        );
11995
11996        // The public page still carries neither.
11997        let resp = router(state)
11998            .oneshot(
11999                Request::builder()
12000                    .uri("/stats")
12001                    .body(Body::empty())
12002                    .unwrap(),
12003            )
12004            .await
12005            .unwrap();
12006        let public = String::from_utf8(
12007            axum::body::to_bytes(resp.into_body(), usize::MAX)
12008                .await
12009                .unwrap()
12010                .to_vec(),
12011        )
12012        .unwrap();
12013        for secret in ["SENTINEL_REDIRECT_NO_LOCATION", "broken.example"] {
12014            assert!(
12015                !public.contains(secret),
12016                "{secret:?} reached the PUBLIC stats page: {public}",
12017            );
12018        }
12019    }
12020
12021    /// **A direct poll must settle the error columns, like the scheduler does.**
12022    ///
12023    /// `add_subscription` polls through `feed::poll_feed` rather than the
12024    /// scheduler, and `poll_feed` writes validators and `last_polled` but never
12025    /// touches `consecutive_errors` — that is the scheduler's job, and this path
12026    /// is not the scheduler.
12027    ///
12028    /// So a feed that was failing, is re-subscribed, and polls SUCCESSFULLY kept
12029    /// its old count and its old cause: the public page went on reporting it
12030    /// under `Failing`, under `badly_broken`, and under a cause, for as long as
12031    /// the stale backoff horizon lasted — up to 24h — while the reader was
12032    /// demonstrably fetching it.
12033    #[tokio::test]
12034    async fn a_successful_direct_poll_clears_a_stale_failure() {
12035        let state = test_state(&[]).await;
12036        let url = "https://recovered.example/f.xml";
12037        store::upsert_feed(
12038            &state.db,
12039            &store::NewFeed {
12040                url: url.to_string(),
12041                ..Default::default()
12042            },
12043        )
12044        .await
12045        .unwrap();
12046        store::bump_feed_errors(&state.db, url, feed::FailureKind::Fetch, "SENTINEL_OLD")
12047            .await
12048            .unwrap();
12049        // Park it on a stale backoff horizon, as a real failing feed would be.
12050        sqlx::query("UPDATE feeds SET next_poll = '2099-01-01T00:00:00Z' WHERE url = ?1")
12051            .bind(url)
12052            .execute(&state.db)
12053            .await
12054            .unwrap();
12055
12056        // The publisher is fixed: a successful poll happens on this path.
12057        feed::settle_poll(
12058            &state.db,
12059            url,
12060            &feed::PollOutcome::NotModified,
12061            state.config.poll_interval,
12062        )
12063        .await;
12064
12065        let row: (i64, Option<String>, Option<String>) = sqlx::query_as(
12066            "SELECT consecutive_errors, last_error_kind, next_poll FROM feeds WHERE url = ?1",
12067        )
12068        .bind(url)
12069        .fetch_one(&state.db)
12070        .await
12071        .unwrap();
12072        assert_eq!(row.0, 0, "a successful direct poll left the error streak");
12073        assert_eq!(row.1, None, "a successful direct poll left a stale cause");
12074        // **The half the first fix missed.** Clearing the count fixed the
12075        // REPORTING; the feed stayed parked until 2099. A working feed must be
12076        // rescheduled on its normal cadence, not left on the failure horizon.
12077        let next = row.2.expect("next_poll was cleared to NULL");
12078        // Not merely "moved off 2099" — rescheduled on the CADENCE, not a
12079        // backoff. A mutation that reschedules successes with backoff_for(1)
12080        // (5 min) also moves it off 2099, so the interval is asserted.
12081        let parsed = chrono::DateTime::parse_from_rfc3339(&next).unwrap();
12082        let delta = parsed
12083            .signed_duration_since(chrono::Utc::now())
12084            .num_seconds();
12085        let cadence = state.config.poll_interval.as_secs() as i64;
12086        assert!(
12087            (cadence - 60..=cadence + 60).contains(&delta),
12088            "expected rescheduling on the {cadence}s cadence, got {delta}s (next_poll={next})"
12089        );
12090    }
12091
12092    /// The mirror case: a first poll that FAILS must be visible at all.
12093    ///
12094    /// `Ok(outcome) => info!(...)` discarded a `PollOutcome::Failed`, so a
12095    /// subscription whose very first fetch failed sat at `consecutive_errors = 0`
12096    /// with a NULL cause — invisible to the page built to count exactly that.
12097    #[tokio::test]
12098    async fn a_failing_direct_poll_is_recorded() {
12099        let state = test_state(&[]).await;
12100        let url = "https://born-broken.example/f.xml";
12101        store::upsert_feed(
12102            &state.db,
12103            &store::NewFeed {
12104                url: url.to_string(),
12105                ..Default::default()
12106            },
12107        )
12108        .await
12109        .unwrap();
12110
12111        feed::settle_poll(
12112            &state.db,
12113            url,
12114            &feed::PollOutcome::Failed {
12115                backoff: std::time::Duration::from_secs(300),
12116                kind: feed::FailureKind::Parse,
12117                detail: "SENTINEL_BORN_BROKEN".to_string(),
12118            },
12119            state.config.poll_interval,
12120        )
12121        .await;
12122
12123        let row: (i64, Option<String>, Option<String>) = sqlx::query_as(
12124            "SELECT consecutive_errors, last_error_kind, next_poll FROM feeds WHERE url = ?1",
12125        )
12126        .bind(url)
12127        .fetch_one(&state.db)
12128        .await
12129        .unwrap();
12130        assert_eq!(row.0, 1, "a failed first poll was not counted");
12131        assert_eq!(
12132            row.1.as_deref(),
12133            Some("parse"),
12134            "its cause was not recorded"
12135        );
12136        // And it is BACKED OFF on the schedule the scheduler would use — not
12137        // left with a NULL next_poll that `due_feeds` sorts first and re-polls
12138        // on the very next tick.
12139        let next = row.2.expect("a failed direct poll left next_poll NULL");
12140        let parsed = chrono::DateTime::parse_from_rfc3339(&next).unwrap();
12141        let delta = parsed
12142            .signed_duration_since(chrono::Utc::now())
12143            .num_seconds();
12144        assert!(
12145            (240..=360).contains(&delta),
12146            "expected ~300s backoff after one failure, got {delta}s (next_poll={next})"
12147        );
12148    }
12149
12150    /// **The breakdown must sum to the Failing figure above it.**
12151    ///
12152    /// The histogram counts `last_error_kind IS NOT NULL`; `Failing` counts
12153    /// `consecutive_errors > 0`. On a migrated database every row that was
12154    /// already failing has a NULL kind — correctly, it was never recorded — so
12155    /// the two do not reconcile and the page shows "70 failing" beside "3
12156    /// fetch" with 67 silently unaccounted for. On deploy day the row vanishes
12157    /// entirely while the prose still promises a breakdown.
12158    ///
12159    /// An explicit `unknown` bucket is the honest shape: the page says how many
12160    /// it cannot explain rather than omitting them.
12161    #[tokio::test]
12162    async fn the_failure_breakdown_accounts_for_every_failing_feed() {
12163        let state = test_state(&[]).await;
12164        // Two legacy rows: failing, with no recorded cause.
12165        for url in [
12166            "https://legacy1.example/f.xml",
12167            "https://legacy2.example/f.xml",
12168        ] {
12169            store::upsert_feed(
12170                &state.db,
12171                &store::NewFeed {
12172                    url: url.to_string(),
12173                    next_poll: Some("2099-01-01T00:00:00Z".to_string()),
12174                    ..Default::default()
12175                },
12176            )
12177            .await
12178            .unwrap();
12179            sqlx::query("UPDATE feeds SET consecutive_errors = 4 WHERE url = ?1")
12180                .bind(url)
12181                .execute(&state.db)
12182                .await
12183                .unwrap();
12184        }
12185        // One row with a recorded cause.
12186        store::upsert_feed(
12187            &state.db,
12188            &store::NewFeed {
12189                url: "https://known.example/f.xml".to_string(),
12190                next_poll: Some("2099-01-01T00:00:00Z".to_string()),
12191                ..Default::default()
12192            },
12193        )
12194        .await
12195        .unwrap();
12196        store::bump_feed_errors(
12197            &state.db,
12198            "https://known.example/f.xml",
12199            feed::FailureKind::Status,
12200            "SENTINEL",
12201        )
12202        .await
12203        .unwrap();
12204
12205        let now = chrono::Utc::now();
12206        let health = store::poll_health(
12207            &state.db,
12208            &now.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
12209            &(now - chrono::Duration::hours(1)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
12210        )
12211        .await
12212        .unwrap();
12213        let counted: i64 = health.failure_kinds.iter().map(|(_, n)| n).sum();
12214        assert_eq!(
12215            counted, health.in_backoff,
12216            "the breakdown ({counted}) does not account for all {} failing feeds: {:?}",
12217            health.in_backoff, health.failure_kinds,
12218        );
12219        assert!(
12220            health
12221                .failure_kinds
12222                .iter()
12223                .any(|(k, n)| k == "unknown" && *n == 2),
12224            "no unknown bucket for the legacy rows: {:?}",
12225            health.failure_kinds,
12226        );
12227    }
12228
12229    /// **The breakdown is ordered by count, and the assertion can see it.**
12230    ///
12231    /// The first version of this asserted with three `contains` calls, which
12232    /// cannot observe order — deleting `ORDER BY` from the query passed.
12233    #[tokio::test]
12234    async fn the_failure_breakdown_is_ordered_by_count() {
12235        let state = test_state(&[]).await;
12236        for (url, kind, n) in [
12237            ("https://p1.example/f.xml", feed::FailureKind::Parse, 1),
12238            ("https://f1.example/f.xml", feed::FailureKind::Fetch, 1),
12239            ("https://f2.example/f.xml", feed::FailureKind::Fetch, 1),
12240            ("https://f3.example/f.xml", feed::FailureKind::Fetch, 1),
12241            ("https://s1.example/f.xml", feed::FailureKind::Status, 1),
12242            ("https://s2.example/f.xml", feed::FailureKind::Status, 1),
12243        ] {
12244            store::upsert_feed(
12245                &state.db,
12246                &store::NewFeed {
12247                    url: url.to_string(),
12248                    next_poll: Some("2099-01-01T00:00:00Z".to_string()),
12249                    ..Default::default()
12250                },
12251            )
12252            .await
12253            .unwrap();
12254            for _ in 0..n {
12255                store::bump_feed_errors(&state.db, url, kind, "d")
12256                    .await
12257                    .unwrap();
12258            }
12259        }
12260        let now = chrono::Utc::now();
12261        let health = store::poll_health(
12262            &state.db,
12263            &now.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
12264            &(now - chrono::Duration::hours(1)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
12265        )
12266        .await
12267        .unwrap();
12268        let labels: Vec<&str> = health
12269            .failure_kinds
12270            .iter()
12271            .map(|(k, _)| k.as_str())
12272            .collect();
12273        assert_eq!(
12274            labels,
12275            ["fetch", "status", "parse"],
12276            "not ordered by count, descending: {:?}",
12277            health.failure_kinds,
12278        );
12279    }
12280
12281    /// **Failing feeds are grouped by CAUSE, and still never named.**
12282    ///
12283    /// `badly_broken` could say that sixty feeds were failing and not whether
12284    /// that was sixty dead publishers or one bug here. It was the latter — #159,
12285    /// a `304 Not Modified` read as a malformed redirect — and the page could
12286    /// not say so, which is most of why it went unexamined.
12287    ///
12288    /// The second half of this test is the constraint that shapes the first:
12289    /// `/stats` is public and promises machines-not-people, *never which feed
12290    /// and never whose*. A histogram of causes keeps that promise; a list of
12291    /// failing URLs would break it, and is the obvious way to build this.
12292    #[tokio::test]
12293    async fn stats_groups_failures_by_cause_without_naming_any_feed() {
12294        let state = test_state(&[]).await;
12295        for (url, kind, detail, errors) in [
12296            // Detail strings are distinctive SENTINELS, not plausible English.
12297            // A first pass used "not a feed", which the page's own explanation
12298            // of the `parse` kind contains verbatim — the privacy assertion
12299            // fired on static copy rather than on a leak. A sentinel cannot
12300            // collide with prose.
12301            (
12302                "https://a.example/f.xml",
12303                feed::FailureKind::Fetch,
12304                "SENTINEL_CONNREFUSED",
12305                3,
12306            ),
12307            (
12308                "https://b.example/f.xml",
12309                feed::FailureKind::Fetch,
12310                "SENTINEL_DNSFAIL",
12311                2,
12312            ),
12313            (
12314                "https://c.example/f.xml",
12315                feed::FailureKind::Status,
12316                "SENTINEL_404",
12317                1,
12318            ),
12319            (
12320                "https://d.example/f.xml",
12321                feed::FailureKind::Parse,
12322                "SENTINEL_UNPARSEABLE",
12323                1,
12324            ),
12325        ] {
12326            store::upsert_feed(
12327                &state.db,
12328                &store::NewFeed {
12329                    url: url.to_string(),
12330                    next_poll: Some("2099-01-01T00:00:00Z".to_string()),
12331                    ..Default::default()
12332                },
12333            )
12334            .await
12335            .unwrap();
12336            for _ in 0..errors {
12337                store::bump_feed_errors(&state.db, url, kind, detail)
12338                    .await
12339                    .unwrap();
12340            }
12341        }
12342
12343        let resp = router(state.clone())
12344            .oneshot(
12345                Request::builder()
12346                    .uri("/stats")
12347                    .body(Body::empty())
12348                    .unwrap(),
12349            )
12350            .await
12351            .unwrap();
12352        assert_eq!(resp.status(), StatusCode::OK);
12353        let body = String::from_utf8(
12354            axum::body::to_bytes(resp.into_body(), usize::MAX)
12355                .await
12356                .unwrap()
12357                .to_vec(),
12358        )
12359        .unwrap();
12360
12361        // Descending by count: two fetch, then one each, tie-broken by name.
12362        assert!(
12363            body.contains("2 fetch") && body.contains("1 status") && body.contains("1 parse"),
12364            "the cause histogram did not render: {body}",
12365        );
12366
12367        // **The privacy half.** No feed URL, host, or error detail reaches the
12368        // public page — only counts by kind.
12369        for secret in [
12370            "a.example",
12371            "b.example",
12372            "c.example",
12373            "d.example",
12374            "SENTINEL_CONNREFUSED",
12375            "SENTINEL_DNSFAIL",
12376            "SENTINEL_404",
12377            "SENTINEL_UNPARSEABLE",
12378        ] {
12379            assert!(
12380                !body.contains(secret),
12381                "{secret:?} reached the PUBLIC stats page: {body}",
12382            );
12383        }
12384    }
12385
12386    /// `/health` must prove the process can reach its database, and must report
12387    /// the loop state without letting it change the status code.
12388    #[tokio::test]
12389    async fn health_checks_the_database_and_reports_the_loops() {
12390        let state = test_state(&[]).await;
12391        let body_of = |state: AppState| async move {
12392            let resp = router(state)
12393                .oneshot(
12394                    Request::builder()
12395                        .uri("/health")
12396                        .body(Body::empty())
12397                        .unwrap(),
12398                )
12399                .await
12400                .unwrap();
12401            let status = resp.status();
12402            let body = String::from_utf8(
12403                axum::body::to_bytes(resp.into_body(), usize::MAX)
12404                    .await
12405                    .unwrap()
12406                    .to_vec(),
12407            )
12408            .unwrap();
12409            (status, body)
12410        };
12411
12412        // The boot stamp is what `main` sets; the router alone does not, so this
12413        // starts "unknown" and the uptime branch below drives it explicitly.
12414        state
12415            .runtime_health
12416            .set_started_at(chrono::Utc::now().timestamp());
12417
12418        let (status, body) = body_of(state.clone()).await;
12419        assert_eq!(status, StatusCode::OK);
12420        assert!(
12421            body.contains("db: ok"),
12422            "health did not probe the DB: {body}"
12423        );
12424        assert!(
12425            body.contains("uptime:"),
12426            "no uptime — the first thing anyone asks about a container that may \
12427             be restarting: {body}"
12428        );
12429        assert!(body.contains("poller:"), "no scheduler heartbeat: {body}");
12430        assert!(body.contains("polling-paused: no"), "{body}");
12431        assert!(body.contains("backend:"), "{body}");
12432        assert!(body.contains("oauth-runtime:"), "{body}");
12433
12434        // A watermark pause is REPORTED but must not fail the check. A failed
12435        // check DEREGISTERS this machine from the proxy — and it is the only
12436        // machine — so it would turn "feeds are behind" into "the site is down"
12437        // for as long as the disk stays full.
12438        state.runtime_health.set_watermark(true);
12439        state.runtime_health.set_schedulers_enabled(true);
12440        let (status, body) = body_of(state.clone()).await;
12441        assert_eq!(
12442            status,
12443            StatusCode::OK,
12444            "a watermark pause must not fail the liveness check: {body}"
12445        );
12446        assert!(body.contains("polling-paused: yes"), "{body}");
12447        // Schedulers on but no tick yet — and that must not read as "0s ago",
12448        // which is the healthiest possible answer to an unanswered question.
12449        assert!(
12450            body.contains("poller: not-yet-ticked"),
12451            "a never-ticked poller must say so: {body}"
12452        );
12453
12454        // A stale heartbeat is likewise reported, not fatal.
12455        let stale_after = health_tick_stale_secs(configured_poll_tick());
12456        let long_ago = chrono::Utc::now().timestamp() - (stale_after + 60);
12457        state.runtime_health.poll_tick_completed(long_ago);
12458        let (status, body) = body_of(state.clone()).await;
12459        assert_eq!(
12460            status,
12461            StatusCode::OK,
12462            "a stale poller must not 503: {body}"
12463        );
12464        assert!(body.contains("poller: stale"), "{body}");
12465
12466        // **A poller that has never ticked stops being benign.**
12467        //
12468        // In a crash loop with 30 s+ boot cycles the poller never reaches its
12469        // first tick, so `not-yet-ticked` was reported forever and the heartbeat
12470        // could not detect the one failure mode the startup delays were added
12471        // for. It is read against uptime now.
12472        state.runtime_health.poll_tick_completed(0); // reset to "never"
12473        state
12474            .runtime_health
12475            .set_started_at(chrono::Utc::now().timestamp() - (HEALTH_FIRST_TICK_GRACE_SECS + 60));
12476        let (status, body) = body_of(state.clone()).await;
12477        assert_eq!(status, StatusCode::OK);
12478        assert!(
12479            body.contains("poller: stale never-ticked"),
12480            "a poller that never ticked long after boot still reads as benign: {body}"
12481        );
12482
12483        // A closed pool is a real outage: nothing can be served, and a restart is
12484        // the correct response. THIS is what the status code is for.
12485        state.db.close().await;
12486        let (status, body) = body_of(state.clone()).await;
12487        assert_eq!(
12488            status,
12489            StatusCode::SERVICE_UNAVAILABLE,
12490            "an unreachable database must fail the check: {body}"
12491        );
12492        assert!(body.starts_with("FAIL"), "{body}");
12493        // Coarse, not the raw sqlx error: an unauthenticated caller learning
12494        // exactly which failure it hit is an attack-progress oracle, and this
12495        // endpoint is exempt from the origin lock.
12496        assert!(
12497            !body.contains("PoolClosed") && !body.contains("sqlx"),
12498            "health leaked the raw database error to an unauthenticated caller: {body}"
12499        );
12500    }
12501
12502    /// The staleness threshold must track the configured tick.
12503    ///
12504    /// Hardcoded at 15 minutes, an operator who raised
12505    /// `FEATHERREADER_POLL_TICK_SECS` above 900 got a permanent `poller: stale`
12506    /// in the body the deployment docs tell them to alert on.
12507    #[test]
12508    fn the_stale_threshold_follows_the_poll_tick() {
12509        // A fast tick keeps the floor — five 60 s ticks is 5 minutes, and
12510        // alerting that early would fire on any brief hiccup.
12511        assert_eq!(
12512            health_tick_stale_secs(Duration::from_secs(60)),
12513            HEALTH_TICK_STALE_FLOOR_SECS
12514        );
12515        // A slow tick raises it, so a legitimately-configured loop is never
12516        // permanently "stale".
12517        let slow = Duration::from_secs(30 * 60);
12518        assert!(
12519            health_tick_stale_secs(slow) > slow.as_secs() as i64,
12520            "a 30-minute tick must not be stale after one interval"
12521        );
12522        assert_eq!(health_tick_stale_secs(slow), 30 * 60 * 5);
12523        // And it cannot overflow into nonsense on an absurd value.
12524        assert!(health_tick_stale_secs(Duration::from_secs(u64::MAX)) > 0);
12525    }
12526
12527    /// `/stats` must distinguish "nothing is polling" from "polling is fine".
12528    ///
12529    /// `polling_paused` alone rendered "running" for three different states,
12530    /// including the two where nothing polls at all — on the page added to
12531    /// answer exactly that question.
12532    #[tokio::test]
12533    async fn stats_does_not_call_a_stopped_poller_running() {
12534        let state = test_state(&[]).await;
12535        let render = |state: AppState| async move {
12536            let resp = router(state)
12537                .oneshot(
12538                    Request::builder()
12539                        .uri("/stats")
12540                        .body(Body::empty())
12541                        .unwrap(),
12542                )
12543                .await
12544                .unwrap();
12545            assert_eq!(resp.status(), StatusCode::OK);
12546            String::from_utf8(
12547                axum::body::to_bytes(resp.into_body(), usize::MAX)
12548                    .await
12549                    .unwrap()
12550                    .to_vec(),
12551            )
12552            .unwrap()
12553        };
12554
12555        // Schedulers never started: not "running".
12556        let body = render(state.clone()).await;
12557        assert!(
12558            body.contains("the poller is not running on this instance"),
12559            "a disabled poller renders as healthy"
12560        );
12561
12562        // Started, but no tick has finished yet.
12563        state.runtime_health.set_schedulers_enabled(true);
12564        let body = render(state.clone()).await;
12565        assert!(
12566            body.contains("no poll has finished since this instance booted"),
12567            "a poller that has not ticked renders as healthy"
12568        );
12569
12570        // Ticking: running.
12571        state
12572            .runtime_health
12573            .poll_tick_completed(chrono::Utc::now().timestamp());
12574        let body = render(state.clone()).await;
12575        assert!(
12576            body.contains("running"),
12577            "a healthy poller must read as running"
12578        );
12579
12580        // Paused at the watermark still wins over "running".
12581        state.runtime_health.set_watermark(true);
12582        let body = render(state.clone()).await;
12583        assert!(
12584            body.contains("the cache is at its size limit"),
12585            "a watermark pause is hidden once the poller is ticking"
12586        );
12587    }
12588
12589    /// **An UNMEASURED database must not fail the check.**
12590    ///
12591    /// `/health` is the one path exempt from the Cloudflare origin lock and
12592    /// absent from the rate limiter, and `DbProbeGuard` releases its claim on
12593    /// drop WITHOUT recording a verdict — so a cancelled request (a client
12594    /// disconnect is enough) leaves the verdict at "none", and a concurrent
12595    /// caller reads it. Treating that as a failure turned an unauthenticated
12596    /// request into a lever on the only signal the platform acts on. The
12597    /// previous version of this code had the opposite bug and reported `ok` for
12598    /// a database nothing had read; "unknown" is neither.
12599    #[tokio::test]
12600    async fn health_reports_an_unmeasured_database_without_failing() {
12601        use crate::runtime_health::DbProbe;
12602        let state = test_state(&[]).await;
12603
12604        // Hold the probe claim, exactly as an in-flight request would, and never
12605        // record a verdict — the cancelled-request state.
12606        let held = state
12607            .runtime_health
12608            .begin_db_probe()
12609            .unwrap_or_else(|_| panic!("a fresh RuntimeHealth must grant the first claim"));
12610
12611        let resp = router(state.clone())
12612            .oneshot(
12613                Request::builder()
12614                    .uri("/health")
12615                    .body(Body::empty())
12616                    .unwrap(),
12617            )
12618            .await
12619            .unwrap();
12620        let status = resp.status();
12621        let body = String::from_utf8(
12622            axum::body::to_bytes(resp.into_body(), usize::MAX)
12623                .await
12624                .unwrap()
12625                .to_vec(),
12626        )
12627        .unwrap();
12628        drop(held);
12629
12630        assert_eq!(
12631            status,
12632            StatusCode::OK,
12633            "an unmeasured database failed the check, which an unauthenticated \
12634             caller can cause on demand: {body}"
12635        );
12636        assert!(
12637            body.contains("db: unknown"),
12638            "the unmeasured state must still be REPORTED: {body}"
12639        );
12640        assert!(!body.starts_with("FAIL"), "{body}");
12641        // **And it must not read as `ok` either.** `fly.toml` tells operators to
12642        // alert on the BODY for everything the status code ignores, so a first
12643        // line identical to the healthy one makes a monitor keying on `^ok` read
12644        // green in exactly the state this enum exists to surface.
12645        assert!(
12646            !body.starts_with("ok"),
12647            "the unmeasured state is indistinguishable from healthy to a \
12648             body-matching monitor: {body}"
12649        );
12650        assert!(body.starts_with("unknown"), "{body}");
12651
12652        // **A BORROWED failure must 503 too.**
12653        //
12654        // This previously recorded `Failed` and then closed the pool — but
12655        // `record` consumes the guard and releases the claim, so the request won
12656        // it, ran a live probe against the closed pool, and failed on its own.
12657        // The 503 passed for the wrong reason and the borrow path — the whole
12658        // point of the three-state enum on the read side — had no coverage.
12659        //
12660        // Holding the claim forces the borrow, so the recorded verdict is what
12661        // gets reported.
12662        let held = state
12663            .runtime_health
12664            .begin_db_probe()
12665            .unwrap_or_else(|_| panic!("claim"));
12666        state
12667            .runtime_health
12668            .record_for_test(DbProbe::Failed("unavailable".to_string()));
12669        let resp = router(state.clone())
12670            .oneshot(
12671                Request::builder()
12672                    .uri("/health")
12673                    .body(Body::empty())
12674                    .unwrap(),
12675            )
12676            .await
12677            .unwrap();
12678        let status = resp.status();
12679        let body = String::from_utf8(
12680            axum::body::to_bytes(resp.into_body(), usize::MAX)
12681                .await
12682                .unwrap()
12683                .to_vec(),
12684        )
12685        .unwrap();
12686        drop(held);
12687        assert_eq!(
12688            status,
12689            StatusCode::SERVICE_UNAVAILABLE,
12690            "a BORROWED failure verdict must fail the check, not just a freshly \
12691             measured one: {body}"
12692        );
12693        assert!(body.starts_with("FAIL"), "{body}");
12694
12695        state.db.close().await;
12696        let resp = router(state.clone())
12697            .oneshot(
12698                Request::builder()
12699                    .uri("/health")
12700                    .body(Body::empty())
12701                    .unwrap(),
12702            )
12703            .await
12704            .unwrap();
12705        assert_eq!(
12706            resp.status(),
12707            StatusCode::SERVICE_UNAVAILABLE,
12708            "a measured database failure must still fail the check"
12709        );
12710    }
12711
12712    /// **A disconnected client must not be able to cancel the probe.**
12713    ///
12714    /// Axum drops the handler future when a caller goes away. With the probe
12715    /// inline that dropped it mid-flight and released the claim WITHOUT
12716    /// recording a verdict — which let an unauthenticated caller manufacture the
12717    /// no-verdict state on demand and freeze what every other caller, including
12718    /// Fly's own check, reads. The probe runs detached now, so the verdict is
12719    /// recorded whatever happens to the request that started it.
12720    #[tokio::test]
12721    async fn an_abandoned_request_still_records_its_probe() {
12722        use crate::runtime_health::DbProbe;
12723        let state = test_state(&[]).await;
12724        let rh = state.runtime_health.clone();
12725
12726        // Drive /health and abandon it immediately — the disconnect case.
12727        let app = router(state.clone());
12728        let fut = app.oneshot(
12729            Request::builder()
12730                .uri("/health")
12731                .body(Body::empty())
12732                .unwrap(),
12733        );
12734        let handle = tokio::spawn(fut);
12735        handle.abort();
12736        let _ = handle.await;
12737
12738        // The detached probe still completes and publishes a verdict, so the
12739        // claim is free and the next caller gets a MEASURED answer.
12740        for _ in 0..50 {
12741            if rh.begin_db_probe().is_ok() {
12742                break;
12743            }
12744            tokio::time::sleep(Duration::from_millis(20)).await;
12745        }
12746        let resp = router(state.clone())
12747            .oneshot(
12748                Request::builder()
12749                    .uri("/health")
12750                    .body(Body::empty())
12751                    .unwrap(),
12752            )
12753            .await
12754            .unwrap();
12755        let body = String::from_utf8(
12756            axum::body::to_bytes(resp.into_body(), usize::MAX)
12757                .await
12758                .unwrap()
12759                .to_vec(),
12760        )
12761        .unwrap();
12762        assert!(
12763            body.contains("db: ok"),
12764            "after an abandoned request the next caller still reads an \
12765             unmeasured database — the probe was cancelled with it: {body}"
12766        );
12767        // Sanity: the type still distinguishes the three states.
12768        assert_ne!(DbProbe::Unknown, DbProbe::Ok);
12769    }
12770
12771    /// **The probe must read a real page.**
12772    ///
12773    /// `SELECT 1` compiles to `Init/Integer/ResultRow/Halt` — no `OpenRead`, so
12774    /// it never touches a b-tree and returns success against a corrupted
12775    /// database. Asserted by asking SQLite what the statement actually compiles
12776    /// to, so it survives someone "simplifying" the query later.
12777    #[tokio::test]
12778    async fn the_health_probe_opens_a_real_table() {
12779        use sqlx::Row;
12780        let state = test_state(&[]).await;
12781        // `EXPLAIN` lists the VM program; the `opcode` column is the second.
12782        let opcodes = |sql: &'static str| {
12783            let db = state.db.clone();
12784            async move {
12785                sqlx::query(sql)
12786                    .fetch_all(&db)
12787                    .await
12788                    .unwrap()
12789                    .into_iter()
12790                    .map(|r| r.get::<String, _>("opcode"))
12791                    .collect::<Vec<String>>()
12792            }
12793        };
12794
12795        // The statement `health_db_probe` really runs — it is the sole path, so
12796        // there is no second string for the handler to use instead.
12797        let explain: &'static str =
12798            Box::leak(format!("EXPLAIN {HEALTH_DB_PROBE_SQL}").into_boxed_str());
12799        let probe = opcodes(explain).await;
12800        // And the probe itself works against a real schema.
12801        assert!(
12802            health_db_probe(&state.db).await.is_ok(),
12803            "the probe does not run against the real schema",
12804        );
12805        assert!(
12806            probe.iter().any(|op| op == "OpenRead"),
12807            "the health probe reads no page; it cannot detect a broken database: {probe:?}"
12808        );
12809        // And the bare form genuinely does not, which is the whole point.
12810        let bare = opcodes("EXPLAIN SELECT 1").await;
12811        assert!(
12812            !bare.iter().any(|op| op == "OpenRead"),
12813            "premise check failed: bare SELECT 1 now reads a page: {bare:?}"
12814        );
12815    }
12816
12817    /// A fresh instance says "never", not "0" — which would read as "polled
12818    /// just now", the opposite of the truth.
12819    #[test]
12820    fn an_instance_that_has_never_polled_says_so() {
12821        assert_eq!(humanise_ago(None), "never");
12822        assert_eq!(humanise_ago(Some(0)), "0s ago");
12823        assert_eq!(humanise_ago(Some(59)), "59s ago");
12824        assert_eq!(humanise_ago(Some(60)), "1m ago");
12825        assert_eq!(humanise_ago(Some(3600)), "1h 0m ago");
12826        assert_eq!(humanise_ago(Some(11_460)), "3h 11m ago");
12827    }
12828
12829    /// A sidecar mock that answers `/internal/repo` listRecords with one saved
12830    /// record, and anything else with an empty list. Serves repeatedly.
12831    async fn spawn_saved_sidecar(saved_url: &str, saved_title: &str) -> String {
12832        use tokio::io::{AsyncReadExt, AsyncWriteExt};
12833        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
12834        let addr = listener.local_addr().unwrap();
12835        let (url, title) = (saved_url.to_string(), saved_title.to_string());
12836        tokio::spawn(async move {
12837            loop {
12838                let Ok((mut sock, _)) = listener.accept().await else {
12839                    break;
12840                };
12841                let mut buf = vec![0u8; 8192];
12842                let Ok(n) = sock.read(&mut buf).await else {
12843                    continue;
12844                };
12845                let req = String::from_utf8_lossy(&buf[..n]).to_string();
12846                let wants_saved = req.contains("community.lexicon.rss.saved");
12847                let records = if wants_saved {
12848                    serde_json::json!([{
12849                        "uri": "at://did:plc:x/community.lexicon.rss.saved/rk1",
12850                        "cid": "bafy",
12851                        "value": {
12852                            "$type": "community.lexicon.rss.saved",
12853                            "url": url,
12854                            "title": title,
12855                            "createdAt": "2026-01-01T00:00:00Z"
12856                        }
12857                    }])
12858                } else {
12859                    serde_json::json!([])
12860                };
12861                let body = serde_json::json!({
12862                    "ok": true, "data": { "records": records }
12863                })
12864                .to_string();
12865                let resp = format!(
12866                    "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
12867                    body.len(), body
12868                );
12869                let _ = sock.write_all(resp.as_bytes()).await;
12870                let _ = sock.flush().await;
12871            }
12872        });
12873        format!("http://{addr}")
12874    }
12875
12876    /// A sidecar mock serving `n` distinct saved records, none of them cached
12877    /// locally — the shape that exercises the uncached-row append.
12878    async fn spawn_saved_sidecar_many(n: usize, subscribed_feed: &str) -> String {
12879        let feed = subscribed_feed.to_string();
12880        use tokio::io::{AsyncReadExt, AsyncWriteExt};
12881        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
12882        let addr = listener.local_addr().unwrap();
12883        tokio::spawn(async move {
12884            loop {
12885                let Ok((mut sock, _)) = listener.accept().await else {
12886                    break;
12887                };
12888                let mut buf = vec![0u8; 8192];
12889                let Ok(read) = sock.read(&mut buf).await else {
12890                    continue;
12891                };
12892                let req = String::from_utf8_lossy(&buf[..read]).to_string();
12893                let records = if req.contains("community.lexicon.rss.saved") {
12894                    serde_json::Value::Array(
12895                        (0..n)
12896                            .map(|i| {
12897                                serde_json::json!({
12898                                    "uri": format!("at://did:plc:x/community.lexicon.rss.saved/rk{i}"),
12899                                    "cid": "bafy",
12900                                    "value": {
12901                                        "$type": "community.lexicon.rss.saved",
12902                                        "url": format!("https://elsewhere.example/{i}"),
12903                                        "title": format!("Elsewhere {i}"),
12904                                        "createdAt": "2026-01-01T00:00:00Z"
12905                                    }
12906                                })
12907                            })
12908                            .collect(),
12909                    )
12910                } else if req.contains("community.lexicon.rss.subscription") {
12911                    // Without this the handler's `sync_sub_refs` would REPLACE
12912                    // sub_ref with an empty set on every render, and every
12913                    // sub_ref-scoped read — including the cached starred list
12914                    // this test is about — would come back empty.
12915                    serde_json::json!([{
12916                        "uri": "at://did:plc:x/community.lexicon.rss.subscription/sub1",
12917                        "cid": "bafy",
12918                        "value": {
12919                            "$type": "community.lexicon.rss.subscription",
12920                            "url": feed,
12921                            "createdAt": "2026-01-01T00:00:00Z"
12922                        }
12923                    }])
12924                } else {
12925                    serde_json::json!([])
12926                };
12927                let body =
12928                    serde_json::json!({ "ok": true, "data": { "records": records } }).to_string();
12929                let resp = format!(
12930                    "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
12931                    body.len(), body
12932                );
12933                let _ = sock.write_all(resp.as_bytes()).await;
12934                let _ = sock.flush().await;
12935            }
12936        });
12937        format!("http://{addr}")
12938    }
12939
12940    /// **The pager must not advertise a page the clamp cannot reach.**
12941    ///
12942    /// The page clamp is computed from the CACHED total; the uncached PDS rows
12943    /// are appended to the last page rather than paged. Inflating `total` with
12944    /// them made `page_count` and the "Older →" link point one page past the end:
12945    /// requesting it clamped straight back, re-rendered the same last page, and
12946    /// still offered the link. An infinite "next" that never advances.
12947    #[tokio::test]
12948    async fn the_starred_pager_does_not_advertise_an_unreachable_page() {
12949        let did = "did:plc:pagerloop";
12950        let sidecar = spawn_saved_sidecar_many(80, "https://loop.example/feed.xml").await;
12951        let state = test_state_with_sidecar(&[], &sidecar).await;
12952        store::grant_access(&state.db, did, None, "test", None)
12953            .await
12954            .unwrap();
12955        let feed = store::upsert_feed(
12956            &state.db,
12957            &store::NewFeed {
12958                url: "https://loop.example/feed.xml".to_string(),
12959                title: Some("Loop".to_string()),
12960                ..Default::default()
12961            },
12962        )
12963        .await
12964        .unwrap();
12965        // 250 cached starred entries: the last page holds 50, so 50 + 80 > 100
12966        // and the old arithmetic reported a fourth page.
12967        let entries: Vec<store::NewEntry> = (0..250)
12968            .map(|i| store::NewEntry {
12969                guid: format!("s-{i:04}"),
12970                url: Some(format!("https://loop.example/{i}")),
12971                title: Some(format!("Starred {i:04}")),
12972                published: Some(format!("2026-06-{:02}T00:00:00Z", (i % 28) + 1)),
12973                ..Default::default()
12974            })
12975            .collect();
12976        store::insert_entries(&state.db, feed, &entries, 0)
12977            .await
12978            .unwrap();
12979        store::replace_sub_refs(&state.db, did, &[feed])
12980            .await
12981            .unwrap();
12982        for row in store::list_entries(&state.db, did, store::ListView::All, None, 1_000, 0)
12983            .await
12984            .unwrap()
12985        {
12986            store::mark_starred(&state.db, did, row.id, true)
12987                .await
12988                .unwrap();
12989        }
12990
12991        let cookie = session_cookie(&state, did, None);
12992        let app = router(state.clone());
12993        let get = |uri: &str| {
12994            let (app, cookie, uri) = (app.clone(), cookie.clone(), uri.to_string());
12995            async move {
12996                let resp = app
12997                    .oneshot(
12998                        Request::builder()
12999                            .uri(uri)
13000                            .header(header::COOKIE, cookie)
13001                            .body(Body::empty())
13002                            .unwrap(),
13003                    )
13004                    .await
13005                    .unwrap();
13006                assert_eq!(resp.status(), StatusCode::OK);
13007                String::from_utf8(
13008                    axum::body::to_bytes(resp.into_body(), 16 * 1024 * 1024)
13009                        .await
13010                        .unwrap()
13011                        .to_vec(),
13012                )
13013                .unwrap()
13014            }
13015        };
13016
13017        // 250 cached + 80 uncached = 330 rows over 4 pages. The pager and the
13018        // clamp must agree on that, and EVERY page it offers must have content —
13019        // the original bug advertised a fourth page that clamped back to the
13020        // third and re-rendered it, still offering the link.
13021        let p3 = get("/?view=starred&page=3").await;
13022        assert!(
13023            p3.contains("Page 3 of 4"),
13024            "the pager and the clamp disagree on the total: {}",
13025            p3.split("pager-pos")
13026                .nth(1)
13027                .unwrap_or("")
13028                .chars()
13029                .take(120)
13030                .collect::<String>()
13031        );
13032        // Page 3 is the boundary: the last 50 cached rows, then the first 50
13033        // uncached ones.
13034        assert!(
13035            p3.contains("Elsewhere 0"),
13036            "page 3 should start the uncached run"
13037        );
13038        assert_eq!(
13039            p3.matches("<li class=\"entry").count(),
13040            ENTRIES_PER_PAGE as usize,
13041            "the boundary page is not full"
13042        );
13043
13044        // **The heading, which the previous round broke by deleting this.**
13045        //
13046        // `total` includes the uncached records, so the parenthetical is a
13047        // SUBSET of it, not an addition — "330 entries (80 saved elsewhere)".
13048        // The version that said "plus N" double counted once `total` started
13049        // including them, and N had become page-local in the same commit while
13050        // the template stayed put. It shipped because this assertion was deleted
13051        // rather than updated.
13052        {
13053            let body = &p3;
13054            assert!(
13055                body.contains("330 entries"),
13056                "the heading must count the whole sequence: {}",
13057                body.split("content-count")
13058                    .nth(1)
13059                    .unwrap_or("")
13060                    .chars()
13061                    .take(120)
13062                    .collect::<String>()
13063            );
13064            assert!(
13065                body.contains("(80 saved elsewhere)"),
13066                "the heading must say how many of the total the cache cannot show, \
13067                 as a whole-list figure and not a per-page one: {}",
13068                body.split("content-count")
13069                    .nth(1)
13070                    .unwrap_or("")
13071                    .chars()
13072                    .take(120)
13073                    .collect::<String>()
13074            );
13075            assert!(
13076                !body.contains("plus 50") && !body.contains("plus 80"),
13077                "the heading is adding the uncached rows to a total that already \
13078                 includes them"
13079            );
13080        }
13081
13082        let p4 = get("/?view=starred&page=4").await;
13083        assert!(
13084            p4.contains("Page 4 of 4"),
13085            "page 4 was advertised but clamps somewhere else — the unreachable-page bug"
13086        );
13087        assert_eq!(
13088            p4.matches("<li class=\"entry").count(),
13089            30,
13090            "page 4 should hold the remaining 30 uncached records"
13091        );
13092        assert!(
13093            p4.contains("Elsewhere 79"),
13094            "the LAST saved record is unreachable — it can only be removed from here"
13095        );
13096
13097        // No uncached record appears on two pages.
13098        assert!(
13099            !p4.contains("Elsewhere 0"),
13100            "an uncached record was rendered on more than one page"
13101        );
13102        // Page 1 is all cached — and still reports the same whole-list heading,
13103        // because the parenthetical describes the LIST, not the page.
13104        let first = get("/?view=starred").await;
13105        assert!(
13106            first.contains("330 entries") && first.contains("(80 saved elsewhere)"),
13107            "the heading changed between pages; it describes the list, not the page"
13108        );
13109        assert!(
13110            !first.contains("Elsewhere "),
13111            "uncached saved records leaked onto the first page"
13112        );
13113    }
13114
13115    /// **A saved record whose article is not cached here is still shown.**
13116    ///
13117    /// The starred view is built from local `entries`, so before this a record
13118    /// starred in ANOTHER atproto reader — the portability the shared lexicon
13119    /// exists for — was simply invisible. It now renders from the PDS record,
13120    /// visually distinct, linking straight out.
13121    #[tokio::test]
13122    async fn a_saved_record_with_no_cached_entry_is_shown_as_a_link() {
13123        let did = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
13124        let sidecar =
13125            spawn_saved_sidecar("https://elsewhere.example/article", "Starred elsewhere").await;
13126        let mut state = test_state_with_sidecar(&[did], &sidecar).await;
13127        std::sync::Arc::get_mut(&mut state.config).unwrap().dev_did = Some(did.to_string());
13128
13129        let resp = router(state)
13130            .oneshot(
13131                Request::builder()
13132                    .uri("/?view=starred")
13133                    .body(Body::empty())
13134                    .unwrap(),
13135            )
13136            .await
13137            .unwrap();
13138        assert_eq!(resp.status(), StatusCode::OK);
13139        let body = String::from_utf8(
13140            axum::body::to_bytes(resp.into_body(), usize::MAX)
13141                .await
13142                .unwrap()
13143                .to_vec(),
13144        )
13145        .unwrap();
13146
13147        assert!(
13148            body.contains("Starred elsewhere"),
13149            "the saved record was not rendered at all"
13150        );
13151        assert!(
13152            body.contains("entry-uncached"),
13153            "it was not marked as uncached, so it looks like a normal entry"
13154        );
13155        assert!(
13156            body.contains("https://elsewhere.example/article"),
13157            "the row must link straight to the article"
13158        );
13159        assert!(
13160            !body.contains("/entries/0/"),
13161            "an uncached row must not offer entry actions against a nonexistent id"
13162        );
13163    }
13164
13165    /// **A PDS `createdAt` must not be able to panic the starred view.**
13166    ///
13167    /// `display_date` byte-sliced `p[..10]`. Every prior caller passed a
13168    /// timestamp the feed parser produced; the saved-record path passes a bare
13169    /// string off a PDS record, written by whatever client the reader used. A
13170    /// multi-byte value panicked the handler, and with no catch-panic layer the
13171    /// view stayed down until the record was removed — from that same view.
13172    #[test]
13173    fn a_multibyte_timestamp_does_not_panic_the_date_formatter() {
13174        for hostile in [
13175            "日本語日本語日本",
13176            "é",
13177            "",
13178            "2026",
13179            "🙂🙂🙂🙂🙂🙂🙂🙂🙂🙂🙂",
13180        ] {
13181            let out = display_date(Some(hostile));
13182            assert!(out.chars().count() <= 10, "{hostile:?} -> {out:?}");
13183        }
13184        assert_eq!(display_date(Some("2026-01-01T00:00:00Z")), "2026-01-01");
13185        assert_eq!(display_date(None), "");
13186    }
13187
13188    /// Unsaving makes a DPoP-signed PDS round-trip, which is the stated reason
13189    /// its neighbours are limited. It was added as a route and not added here.
13190    #[test]
13191    fn the_unsave_route_is_rate_limited() {
13192        use axum::http::Method;
13193        assert!(is_rate_limited_path("/saved/3abc/delete", &Method::POST));
13194        // And the neighbours still are.
13195        assert!(is_rate_limited_path("/entries/1/star", &Method::POST));
13196    }
13197
13198    /// **The probe detects a broken database — asserted through `/health`
13199    /// itself, not through a string.**
13200    ///
13201    /// A named constant did not bind the handler: it stayed free to call
13202    /// `query_scalar` with a different literal, so degrading the real probe to
13203    /// `SELECT 1` shipped green twice over. This drops the table the probe reads
13204    /// and asserts the endpoint stops saying `ok` — behaviour no substituted SQL
13205    /// can fake, because `SELECT 1` still succeeds against a wrecked schema.
13206    #[tokio::test]
13207    async fn health_reports_a_broken_database() {
13208        let state = test_state(&[]).await;
13209        // Sanity: healthy first, so the assertion below is about the damage.
13210        assert!(
13211            health_db_probe(&state.db).await.is_ok(),
13212            "the fixture was not healthy to begin with",
13213        );
13214
13215        sqlx::query("DROP TABLE feeds")
13216            .execute(&state.db)
13217            .await
13218            .unwrap();
13219
13220        assert!(
13221            health_db_probe(&state.db).await.is_err(),
13222            "the probe reported success against a database missing the table it \
13223             claims to read; `SELECT 1` would do exactly this",
13224        );
13225
13226        let resp = router(state)
13227            .oneshot(
13228                Request::builder()
13229                    .uri("/health")
13230                    .body(Body::empty())
13231                    .unwrap(),
13232            )
13233            .await
13234            .unwrap();
13235        let body = String::from_utf8(
13236            axum::body::to_bytes(resp.into_body(), usize::MAX)
13237                .await
13238                .unwrap()
13239                .to_vec(),
13240        )
13241        .unwrap();
13242        // The documented contract: the FIRST token is the state.
13243        assert!(
13244            body.starts_with("FAIL"),
13245            "/health did not report FAIL for a broken database: {body}",
13246        );
13247        assert!(
13248            !body.contains("db: ok"),
13249            "/health still called the database ok: {body}",
13250        );
13251    }
13252
13253    /// A sidecar mock for the OPML export: serves one subscription and one
13254    /// folder, except for the collection named in `fail_on`, which answers
13255    /// `500` — the shape a refused (short or unreadable) walk takes at this
13256    /// boundary.
13257    async fn spawn_export_sidecar(fail_on: Option<&'static str>) -> String {
13258        use tokio::io::{AsyncReadExt, AsyncWriteExt};
13259        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
13260        let addr = listener.local_addr().unwrap();
13261        tokio::spawn(async move {
13262            loop {
13263                let Ok((mut sock, _)) = listener.accept().await else {
13264                    break;
13265                };
13266                let mut buf = vec![0u8; 8192];
13267                let Ok(n) = sock.read(&mut buf).await else {
13268                    continue;
13269                };
13270                let req = String::from_utf8_lossy(&buf[..n]).to_string();
13271                let wants = |c: &str| req.contains(c);
13272                if fail_on.is_some_and(wants) {
13273                    let body = r#"{"ok":false,"error":"ShortList"}"#;
13274                    let resp = format!(
13275                        "HTTP/1.1 500 Internal Server Error\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
13276                        body.len(),
13277                        body
13278                    );
13279                    let _ = sock.write_all(resp.as_bytes()).await;
13280                    let _ = sock.flush().await;
13281                    continue;
13282                }
13283                let records = if wants(crate::lexicon::nsid::SUBSCRIPTION) {
13284                    serde_json::json!([{
13285                        "uri": "at://did:plc:exporter/community.lexicon.rss.subscription/sub1",
13286                        "cid": "bafy",
13287                        "value": {
13288                            "$type": crate::lexicon::nsid::SUBSCRIPTION,
13289                            "url": "https://kept.example/feed.xml",
13290                            "title": "Kept",
13291                            // Inside the folder, so the healthy export has to
13292                            // carry BOTH walks' results: an exporter that lost
13293                            // the folder list would flatten this outline out of
13294                            // its group with nothing else changing.
13295                            "folder": "at://did:plc:exporter/community.lexicon.rss.folder/fold1",
13296                            "createdAt": "2026-01-01T00:00:00Z"
13297                        }
13298                    }])
13299                } else if wants(crate::lexicon::nsid::FOLDER) {
13300                    serde_json::json!([{
13301                        "uri": "at://did:plc:exporter/community.lexicon.rss.folder/fold1",
13302                        "cid": "bafy",
13303                        "value": {
13304                            "$type": crate::lexicon::nsid::FOLDER,
13305                            "name": "Kept folder",
13306                            "createdAt": "2026-01-01T00:00:00Z"
13307                        }
13308                    }])
13309                } else {
13310                    serde_json::json!([])
13311                };
13312                let body =
13313                    serde_json::json!({ "ok": true, "data": { "records": records } }).to_string();
13314                let resp = format!(
13315                    "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
13316                    body.len(),
13317                    body
13318                );
13319                let _ = sock.write_all(resp.as_bytes()).await;
13320                let _ = sock.flush().await;
13321            }
13322        });
13323        format!("http://{addr}")
13324    }
13325
13326    /// A sidecar whose every `listRecords` page carries one good record and
13327    /// one with no `uri` — the #177 shape — for any collection.
13328    async fn spawn_malformed_sidecar() -> String {
13329        use tokio::io::{AsyncReadExt, AsyncWriteExt};
13330        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
13331        let addr = listener.local_addr().unwrap();
13332        tokio::spawn(async move {
13333            loop {
13334                let Ok((mut sock, _)) = listener.accept().await else {
13335                    break;
13336                };
13337                let mut buf = vec![0u8; 8192];
13338                let _ = sock.read(&mut buf).await;
13339                let body = serde_json::json!({ "ok": true, "data": { "records": [
13340                    { "uri": "at://did:plc:alerted/c/3labGOOD", "cid": "bafy", "value": {} },
13341                    { "cid": "bafy", "value": {} },
13342                ]}})
13343                .to_string();
13344                let resp = format!(
13345                    "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
13346                    body.len(),
13347                    body
13348                );
13349                let _ = sock.write_all(resp.as_bytes()).await;
13350                let _ = sock.flush().await;
13351            }
13352        });
13353        format!("http://{addr}")
13354    }
13355
13356    async fn page_body(state: AppState, did: &str, uri: &str) -> (StatusCode, String) {
13357        let cookie = session_cookie(&state, did, None);
13358        let resp = router(state)
13359            .oneshot(
13360                Request::builder()
13361                    .uri(uri)
13362                    .header(header::COOKIE, cookie)
13363                    .body(Body::empty())
13364                    .unwrap(),
13365            )
13366            .await
13367            .unwrap();
13368        let status = resp.status();
13369        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
13370            .await
13371            .unwrap();
13372        (status, String::from_utf8_lossy(&body).to_string())
13373    }
13374
13375    /// **0.4.0 step 4: a publication document with neither summary field
13376    /// renders as a title, a date and a link** — 8% of measured documents
13377    /// (37 of 449) carry neither `description` nor `textContent`. That is what
13378    /// an RSS reader shows for a title-only feed, not an error, in the list and
13379    /// on the article page alike.
13380    #[tokio::test]
13381    async fn a_publication_entry_with_no_summary_renders_title_date_and_link() {
13382        let did = "did:plc:displayer";
13383        let state = test_state(&[did]).await;
13384        let url = "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lab";
13385        let feed_id = store::upsert_feed(
13386            &state.db,
13387            &store::NewFeed {
13388                url: url.into(),
13389                title: Some("Quiet Journal".into()),
13390                ..Default::default()
13391            },
13392        )
13393        .await
13394        .unwrap();
13395        store::replace_sub_refs(&state.db, did, &[feed_id])
13396            .await
13397            .unwrap();
13398        store::insert_entries(
13399            &state.db,
13400            feed_id,
13401            &[store::NewEntry {
13402                guid: "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.document/3l2nosumaaa2a"
13403                    .into(),
13404                url: Some("https://quiet.example/no-summary".into()),
13405                title: Some("A title-only article".into()),
13406                published: Some("2026-07-11T00:00:00Z".into()),
13407                content_html: None,
13408                ..Default::default()
13409            }],
13410            0,
13411        )
13412        .await
13413        .unwrap();
13414        let (status, list) = page_body(state.clone(), did, "/?view=all").await;
13415        assert_eq!(status, StatusCode::OK);
13416        assert!(
13417            list.contains("A title-only article"),
13418            "the entry is missing from the list"
13419        );
13420
13421        let id: i64 = sqlx::query_scalar("SELECT id FROM entries WHERE feed_id = ?")
13422            .bind(feed_id)
13423            .fetch_one(&state.db)
13424            .await
13425            .unwrap();
13426        let (status, page) = page_body(state, did, &format!("/entries/{id}")).await;
13427        assert_eq!(
13428            status,
13429            StatusCode::OK,
13430            "the article page failed for an entry with no body"
13431        );
13432        assert!(page.contains("A title-only article"));
13433        assert!(
13434            page.contains("https://quiet.example/no-summary"),
13435            "no link to the original"
13436        );
13437        assert!(
13438            page.contains(r#"<time datetime=""#),
13439            "no date on the article page"
13440        );
13441    }
13442
13443    /// **#177: a malformed record in the reader's own repo is refused, and the
13444    /// reader is told.** Refusing keeps `replace_sub_refs` from dropping the
13445    /// subscription that record was; telling them keeps the stale list from
13446    /// looking like the real one. Both the reading page and the manage page.
13447    #[tokio::test]
13448    async fn a_malformed_subscription_record_raises_an_alert() {
13449        let did = "did:plc:alerted";
13450        for page in ["/", "/manage"] {
13451            let sidecar = spawn_malformed_sidecar().await;
13452            let state = test_state_with_sidecar(&[did], &sidecar).await;
13453            let (status, body) = page_body(state, did, page).await;
13454            assert_eq!(status, StatusCode::OK, "{page} did not render");
13455            assert!(
13456                body.contains(r#"role="alert""#) && body.contains("could not be read"),
13457                "{page} rendered no alert for a refused subscription list"
13458            );
13459            assert!(
13460                body.contains("1 record(s) in your subscription list"),
13461                "{page} gave the generic alert, not the malformed-record one"
13462            );
13463        }
13464    }
13465
13466    /// The control: a healthy listing raises no alert.
13467    #[tokio::test]
13468    async fn a_healthy_subscription_listing_raises_no_alert() {
13469        let did = "did:plc:exporter";
13470        let sidecar = spawn_export_sidecar(None).await;
13471        let state = test_state_with_sidecar(&[did], &sidecar).await;
13472        let (status, body) = page_body(state, did, "/").await;
13473        assert_eq!(status, StatusCode::OK);
13474        assert!(
13475            !body.contains("could not be read"),
13476            "a healthy listing raised an alert"
13477        );
13478    }
13479
13480    /// `GET /opml/export` against the mock, returning `(status, headers, body)`.
13481    async fn export_opml_response(
13482        fail_on: Option<&'static str>,
13483    ) -> (StatusCode, HeaderMap, String) {
13484        let did = "did:plc:exporter";
13485        let sidecar = spawn_export_sidecar(fail_on).await;
13486        let state = test_state_with_sidecar(&[did], &sidecar).await;
13487        let cookie = session_cookie(&state, did, None);
13488        let resp = router(state)
13489            .oneshot(
13490                Request::builder()
13491                    .uri("/opml/export")
13492                    .header(header::COOKIE, cookie)
13493                    .body(Body::empty())
13494                    .unwrap(),
13495            )
13496            .await
13497            .unwrap();
13498        let status = resp.status();
13499        let headers = resp.headers().clone();
13500        let body = String::from_utf8_lossy(
13501            &axum::body::to_bytes(resp.into_body(), usize::MAX)
13502                .await
13503                .unwrap(),
13504        )
13505        .to_string();
13506        (status, headers, body)
13507    }
13508
13509    /// **An empty export is worse than no export, and this is the caller that
13510    /// used to produce one.**
13511    ///
13512    /// `export_opml` read both walks through `unwrap_or_default()`. Now that a
13513    /// truncated walk refuses instead of returning a short list, that turned the
13514    /// refusal into `200 OK` carrying a zero-feed
13515    /// `featherreader-subscriptions.opml` — a blank backup handed over at exactly
13516    /// the moment a locked-out reader reached for one, and the changelog points
13517    /// them at this route as the recovery path.
13518    ///
13519    /// Asserts the three things a reader can actually observe: no success status,
13520    /// no download offered, and no OPML document in the body.
13521    #[tokio::test]
13522    async fn an_export_that_cannot_read_the_subscriptions_serves_no_opml() {
13523        let (status, headers, body) =
13524            export_opml_response(Some(crate::lexicon::nsid::SUBSCRIPTION)).await;
13525
13526        assert_ne!(
13527            status,
13528            StatusCode::OK,
13529            "a failed subscription walk answered 200: {body}",
13530        );
13531        assert!(
13532            !headers.contains_key(header::CONTENT_DISPOSITION),
13533            "a failed subscription walk still offered a download: {headers:?}",
13534        );
13535        assert!(
13536            !body.contains("<opml"),
13537            "a failed subscription walk still served an OPML document: {body}",
13538        );
13539    }
13540
13541    /// The folders half of the same hole. The two walks are separate calls, and
13542    /// fixing only the first leaves an export that silently loses every folder —
13543    /// a flat list that reimports as one, with no sign anything was lost.
13544    #[tokio::test]
13545    async fn an_export_that_cannot_read_the_folders_serves_no_opml() {
13546        let (status, headers, body) =
13547            export_opml_response(Some(crate::lexicon::nsid::FOLDER)).await;
13548
13549        assert_ne!(
13550            status,
13551            StatusCode::OK,
13552            "a failed folder walk answered 200: {body}",
13553        );
13554        assert!(
13555            !headers.contains_key(header::CONTENT_DISPOSITION),
13556            "a failed folder walk still offered a download: {headers:?}",
13557        );
13558        assert!(
13559            !body.contains("<opml"),
13560            "a failed folder walk still served an OPML document: {body}",
13561        );
13562    }
13563
13564    /// The other direction, without which "refuse everything" would pass both
13565    /// tests above: a healthy read still serves the file, with the feed in it.
13566    #[tokio::test]
13567    async fn a_healthy_export_serves_the_subscriptions_as_a_download() {
13568        let (status, headers, body) = export_opml_response(None).await;
13569
13570        assert_eq!(
13571            status,
13572            StatusCode::OK,
13573            "a healthy export did not answer 200"
13574        );
13575        assert_eq!(
13576            headers
13577                .get(header::CONTENT_DISPOSITION)
13578                .and_then(|v| v.to_str().ok()),
13579            Some("attachment; filename=\"featherreader-subscriptions.opml\""),
13580            "a healthy export did not offer the download",
13581        );
13582        assert!(
13583            body.contains("https://kept.example/feed.xml"),
13584            "the exported OPML lost the subscription: {body}",
13585        );
13586        assert!(
13587            body.contains("Kept folder"),
13588            "the exported OPML lost the folder: {body}",
13589        );
13590    }
13591}