Skip to main content

feather_reader/
web.rs

1//! The axum web layer — server-rendered HTML + a dash of htmx, **no SPA**.
2//!
3//! This module owns the HTTP surface: [`router`] builds an `axum::Router` over
4//! the shared [`AppState`], wiring the store, feed, atproto, and config seams into
5//! a small set of typography-first, dark-mode-ready views rendered with
6//! `askama` templates (under `templates/`). Progressive enhancement is a single
7//! vendored `htmx` script plus a tiny keyboard handler (`static/keyboard.js`);
8//! every interaction also works as a plain HTML form POST, so the reader is fully
9//! usable with JavaScript disabled.
10//!
11//! ## HTTP surface
12//!
13//! * `GET  /health` — liveness + version, as `text/plain`.
14//! * `GET  /` — the reader: a folders/feeds sidebar (from the PDS records layer)
15//!   plus the main article list. Query params pick the scope (`?feed=…` /
16//!   `?folder=…` / all) and the view (`?view=unread|all|starred`).
17//! * `GET  /entries/{id}` — the clean, distraction-free reader for one entry,
18//!   with prev/next within the current list.
19//! * `POST /entries/{id}/read` — mark an entry read/unread (htmx row swap).
20//! * `POST /entries/{id}/star` — star/unstar; writes a
21//!   `community.lexicon.rss.saved` record to the user's PDS.
22//! * `POST /read-all` — mark-all-read (per feed via `?feed=…`, else everything).
23//! * `POST /subscriptions` — subscribe by URL (autodiscover → PDS record).
24//! * `POST /subscriptions/{rkey}/delete` — unsubscribe (delete the PDS record).
25//! * `POST /subscriptions/{rkey}/rename` — retitle / move a feed to a folder.
26//! * `POST /folders` — create a folder record.
27//! * `POST /folders/{rkey}/rename` — rename a folder record.
28//! * `POST /folders/{rkey}/delete` — delete a folder record.
29//! * `POST /opml` — OPML import (multipart upload *or* pasted textarea) → bulk
30//!   subscription records in the PDS.
31//! * `GET  /opml/export` — OPML export (records → a downloadable document).
32//! * `GET /login` + `POST /login` + `/oauth/callback` + `/logout` — the atproto
33//!   OAuth sign-in flow (routed through the sidecar).
34//! * `GET /claim?t=<token>` — the follow→invite bot's claim link: an opaque token
35//!   reserving a pre-minted invite code; behaves like a successful `/beta/redeem`
36//!   (sets the reserving cookie → `/login`).
37//! * `POST /bot/claims` — headless, shared-secret (`X-Bot-Secret`) mint of a claim
38//!   code + token/url for the bot to post. Cap-aware (409 when full).
39//!
40//! ## Identity — a cookie-resolved atproto session
41//!
42//! Per-request identity comes from a **signed session cookie** (`fr_session`)
43//! keyed by the logged-in DID, set by `oauth_callback` and read by
44//! `current_session` / `current_did`. For local runs without the sidecar,
45//! [`Config::dev_did`] (env `FEATHERREADER_DEV_DID`) supplies a fallback identity.
46//! All PDS writes route through the [`crate::atproto::SidecarClient`]; a live-PDS
47//! write needs a real OAuth session, but the full write path is built and unit-
48//! tested to the sidecar boundary.
49
50use std::collections::HashMap;
51use std::net::IpAddr;
52use std::sync::Mutex;
53use std::time::{Duration, Instant};
54
55use askama::Template;
56use axum::{
57    extract::{ConnectInfo, DefaultBodyLimit, Multipart, Path, Query, State},
58    http::{header, HeaderMap, StatusCode},
59    middleware::{self, Next},
60    response::{Html, IntoResponse, Redirect, Response},
61    routing::{get, post},
62    Form, Router,
63};
64use serde::Deserialize;
65use std::net::SocketAddr;
66use tower_http::services::{ServeDir, ServeFile};
67use tower_http::set_header::SetResponseHeaderLayer;
68use tower_http::trace::TraceLayer;
69use tracing::{info, warn};
70
71use crate::config::Config;
72use crate::lexicon::{self, Folder, Saved, Subscription};
73use crate::safe_link::SafeLink;
74use crate::{feed, store, AppState, Session, VERSION};
75
76// The OPML import/export module lives at `src/opml.rs` but isn't declared in the
77// crate root (`lib.rs`), which is outside this phase's edit surface. Wire it in
78// here via an explicit path so the reader's OPML routes can use the canonical
79// `parse_opml` / `to_opml` without duplicating that logic.
80#[path = "opml.rs"]
81mod opml;
82
83/// The name of the signed session cookie.
84const SESSION_COOKIE: &str = "fr_session";
85
86/// The name of the short-lived signed **invite** cookie.
87///
88/// Set by `POST /beta/redeem` on a valid, capacity-ok code and consumed by the
89/// OAuth callback. It reserves *intent* to redeem a specific code before the
90/// visitor ever starts the OAuth handshake, so a non-invited visitor can't burn
91/// a sidecar handshake (pre-handshake gate). It carries the invite code, HMAC-
92/// signed with the same key as the session cookie.
93const INVITE_COOKIE: &str = "fr_invite";
94
95/// Browser-binding cookie for an in-flight OAuth login (Rust backend only).
96///
97/// `state` alone cannot stop a login CSRF: it lives in a server-global table, so
98/// a stolen `state` replayed from ANOTHER browser matches just as well as from
99/// the one that started the flow. This cookie is what makes the callback
100/// browser-specific — the pending row stores only its hash, and a callback that
101/// cannot present it is refused.
102const OAUTH_BINDING_COOKIE: &str = "fr_oauth";
103
104/// How long an in-flight login may sit, matching the pending row's own TTL.
105const OAUTH_BINDING_MAX_AGE_SECS: i64 = 600;
106
107/// TTL (seconds) for a minted invite code and for the reserving invite cookie.
108/// Short enough that a reserved-but-unclaimed seat frees quickly.
109const INVITE_TTL_SECS: i64 = 1800;
110
111/// The canonical AGPL-3.0 source repository — surfaced in the footer, the
112/// sign-in pitch, and `/about`.
113const REPO_URL: &str = "https://github.com/justin-stanley/feather-reader";
114
115/// The tip / support link (cloud plan public-experiment UI).
116const KOFI_URL: &str = "https://ko-fi.com/justinstanley";
117
118/// The published crate on crates.io — surfaced on the signed-out landing page.
119const CRATES_URL: &str = "https://crates.io/crates/feather-reader";
120
121/// The Content-Security-Policy applied to every response.
122///
123/// Tuned to keep the app fully working while neutralising injected script:
124/// * `default-src 'self'` — same-origin baseline.
125/// * `script-src 'self'` — only our vendored `htmx.min.js` + `keyboard.js` from
126///   `/static`; **no** `'unsafe-inline'`, so an injected `<script>` or a
127///   `javascript:` href (F4) cannot execute. (The design's templates carry no
128///   inline event handlers — every control is wired in `keyboard.js`.)
129/// * `style-src 'self' 'unsafe-inline'` — the linked stylesheet plus the small
130///   inline styles htmx toggles for its request indicators.
131/// * `img-src 'self' https: data:` — feed content routinely embeds remote
132///   images; allow https + data URIs but not other schemes.
133/// * `form-action 'self'`, `base-uri 'self'`, `frame-ancestors 'none'` — lock
134///   down form posts, `<base>` hijacking, and clickjacking.
135/// * `object-src 'none'` — no plugins.
136const CONTENT_SECURITY_POLICY: &str = "default-src 'self'; \
137     script-src 'self'; \
138     style-src 'self' 'unsafe-inline'; \
139     img-src 'self' https: data:; \
140     font-src 'self'; \
141     connect-src 'self'; \
142     form-action 'self'; \
143     base-uri 'self'; \
144     frame-ancestors 'none'; \
145     object-src 'none'";
146
147/// The resolved identity for the current request.
148///
149/// `did` is the primary key for all per-user local state; `handle` is display
150/// only; `sid` is the opaque server-side session id the cookie carried (needed
151/// so logout can revoke exactly this session). Sourced from the signed cookie
152/// (real login) or, if none, the configured dev DID fallback.
153#[derive(Clone, Debug)]
154struct CurrentUser {
155    did: String,
156    handle: Option<String>,
157    /// The opaque session id, if this identity came from a real cookie session
158    /// (absent for the dev-DID fallback, which has no server-side session row).
159    sid: Option<String>,
160}
161
162/// Resolve the current request's session from the signed cookie, falling back to
163/// the configured dev DID (env `FEATHERREADER_DEV_DID`) for local runs.
164///
165/// The cookie carries an opaque server-minted session id (not the DID). We
166/// verify its HMAC, look the id up in the registry, and — crucially —
167/// **re-check the DID against the closed-beta gate on every request**
168/// ([`store::has_beta_access`]), not just at the OAuth callback, so revoking a
169/// DID's beta seat takes effect immediately for already-issued cookies. (The
170/// gate replaced the old static `ALLOWED_DIDS` check; `ALLOWED_DIDS` remains the
171/// admin-bootstrap seed, granted a seat at startup via `ensure_seed`.)
172async fn current_session(state: &AppState, headers: &HeaderMap) -> Option<CurrentUser> {
173    if let Some(sid) = cookie::verify_session(headers, &state.config.cookie_secret) {
174        if let Some(session) = state.sessions.get(&sid) {
175            if store::has_beta_access(&state.db, &session.did)
176                .await
177                .unwrap_or(false)
178            {
179                return Some(CurrentUser {
180                    did: session.did,
181                    handle: session.handle,
182                    sid: Some(sid),
183                });
184            }
185            // DID no longer holds a beta seat: treat as logged out (and drop the
186            // stale server-side session so the dead cookie can't linger).
187            state.sessions.remove(&sid);
188        }
189    }
190    // No valid cookie: dev fallback only if explicitly configured *and* still
191    // inside the beta gate (seeded via ensure_seed / a redeemed code).
192    if let Some(did) = state.config.dev_did.clone() {
193        if store::has_beta_access(&state.db, &did)
194            .await
195            .unwrap_or(false)
196        {
197            return Some(CurrentUser {
198                did,
199                handle: None,
200                sid: None,
201            });
202        }
203    }
204    None
205}
206
207/// The current request's DID, or `None` when logged out (no cookie, no dev DID).
208async fn current_did(state: &AppState, headers: &HeaderMap) -> Option<String> {
209    current_session(state, headers).await.map(|u| u.did)
210}
211
212/// Build the application router over shared [`AppState`].
213///
214/// Wires the reader routes, the health check, and the `/static` asset mount
215/// (the stylesheet, vendored htmx, and the keyboard handler, served from
216/// `static/` via `ServeDir`). A `TraceLayer` gives per-request tracing.
217pub fn router(state: AppState) -> Router {
218    // The shared per-IP rate limiter for the abuse-prone paths (login, redeem,
219    // and the write endpoints). One instance is cloned into the state closure of
220    // the `rate_limit` middleware.
221    let limiter = RateLimiter::shared();
222    // The trusted client-IP source for the limiter (a proxy header the operator
223    // controls, or the socket peer when unset). Bundled with the limiter so the
224    // middleware derives a spoof-resistant IP.
225    let rl_state = RateLimitState {
226        limiter,
227        trusted_header: state.config.trusted_ip_header.clone(),
228    };
229
230    Router::new()
231        .route("/health", get(health))
232        .route("/about", get(about))
233        .route("/stats", get(stats))
234        .route("/privacy", get(privacy))
235        .route("/terms", get(terms))
236        .route("/manage", get(manage))
237        .route("/", get(index))
238        .route("/entries/{id}", get(entry_view))
239        .route("/entries/{id}/read", post(mark_read))
240        .route("/entries/{id}/star", post(toggle_star))
241        .route("/saved/{rkey}/delete", post(unsave_record))
242        .route("/read-all", post(mark_all_read))
243        .route("/subscriptions", post(add_subscription))
244        .route("/subscriptions/{rkey}/delete", post(delete_subscription))
245        .route("/subscriptions/{rkey}/rename", post(rename_subscription))
246        .route("/folders", post(create_folder))
247        .route("/folders/{rkey}/rename", post(rename_folder))
248        .route("/folders/{rkey}/delete", post(delete_folder))
249        // OPML import takes untrusted uploads: cap the body so a huge upload
250        // can't OOM (residual body-cap), on top of the streamed feed-fetch cap.
251        .route(
252            "/opml",
253            post(import_opml).layer(DefaultBodyLimit::max(OPML_BODY_LIMIT)),
254        )
255        .route("/opml/export", get(export_opml))
256        .route("/login", get(login_form).post(login_submit))
257        .route(
258            "/beta/redeem",
259            get(beta_redeem_form).post(beta_redeem_submit),
260        )
261        // The follow→invite bot's claim link: a public skeet points a new
262        // follower here with an opaque token that reserves a pre-minted code.
263        .route("/claim", get(claim))
264        // Headless bot mint endpoint (shared-secret, not OAuth). Mints a claim
265        // code + returns its token/url for the bot to post.
266        .route("/bot/claims", post(bot_mint_claim))
267        .route("/admin/invites", post(admin_mint_invites))
268        .route("/admin/metrics", get(admin_metrics))
269        .route("/oauth/client-metadata.json", get(oauth_client_metadata))
270        .route("/oauth/jwks.json", get(oauth_jwks))
271        .route("/account/delete", post(account_delete))
272        .route("/oauth/callback", get(oauth_callback))
273        .route("/logout", post(logout))
274        .nest_service("/static", ServeDir::new("static"))
275        // Browsers (and some feed clients) request /favicon.ico at the root
276        // regardless of the <link rel="icon"> tags; serve the same icon that
277        // lives under /static so the bare path stops 404-ing.
278        .route_service("/favicon.ico", ServeFile::new("static/favicon.ico"))
279        // Cache-Control (viral/CDN plan): `public, max-age=300` on the cacheable
280        // logged-out landing + static assets, `no-store` on anything that
281        // rendered a session's private view. Runs *inside* the security layers so
282        // the CSP/nosniff/frame headers are untouched.
283        .layer(middleware::from_fn(cache_control))
284        // Per-IP rate limit on the abuse-prone paths (429 over the limit). Runs
285        // as a middleware so it sees the matched path + the peer IP.
286        .layer(middleware::from_fn_with_state(rl_state, rate_limit))
287        .layer(TraceLayer::new_for_http())
288        // Baseline security headers on *every* response (F4). The CSP is the
289        // backstop that neutralises any XSS that slips past sanitization; the
290        // others harden sniffing, framing, and referrer leakage.
291        .layer(static_header_layer(
292            "content-security-policy",
293            CONTENT_SECURITY_POLICY,
294        ))
295        .layer(static_header_layer("x-content-type-options", "nosniff"))
296        .layer(static_header_layer(
297            "referrer-policy",
298            "strict-origin-when-cross-origin",
299        ))
300        .layer(static_header_layer("x-frame-options", "DENY"))
301        .with_state(state)
302}
303
304/// Body-size ceiling for the OPML import upload: **1 MiB, deliberately below
305/// axum's 2 MiB default.**
306///
307/// The value used to BE the framework default, which made the route's own
308/// `DefaultBodyLimit` layer a no-op: removing the layer changed nothing, so
309/// nothing could test it, and the ceiling this route wanted was whatever the
310/// framework happened to pick. Sized to this route instead — one outline is
311/// ~92 bytes, so 1 MiB carries ~11 000 of them against a per-DID cap
312/// (`max_subs_per_did`, default 500) the import trims to anyway. Anything
313/// larger is not a subscription list.
314///
315/// Being strictly tighter than the default is what makes the layer both real
316/// and pinnable: `opml_import_over_the_route_cap_is_refused_below_the_framework_default`
317/// uploads a payload that only this limit refuses.
318const OPML_BODY_LIMIT: usize = 1024 * 1024;
319
320/// axum's own `DefaultBodyLimit` (2 MiB as of axum 0.8), for the test that
321/// uploads a payload between the two ceilings.
322///
323/// **What matters is only that it EXCEEDS [`OPML_BODY_LIMIT`]**, not that this
324/// number is exact — axum does not export it, so it cannot be imported. The
325/// exceeding is what the test's mutation demonstrates: with the route's layer
326/// removed, a payload of this size is accepted. If axum ever lowers its
327/// default below ours, that mutation stops failing and the compile-time
328/// assertion below is the thing to revisit.
329#[cfg(test)]
330const AXUM_DEFAULT_BODY_LIMIT: usize = 2 * 1024 * 1024;
331
332/// The route's cap must stay strictly tighter than the framework's, or its
333/// layer is a no-op again. A compile error, not a test failure: this is a
334/// property of the two constants, and nothing should be able to build a binary
335/// where it is false.
336#[cfg(test)]
337const _: () = assert!(
338    OPML_BODY_LIMIT < AXUM_DEFAULT_BODY_LIMIT,
339    "OPML_BODY_LIMIT must be tighter than axum's default, or the route's layer does nothing"
340);
341
342/// A response-header layer that sets `name: value` on every response, overriding
343/// any existing header of that name. `name`/`value` must be valid static header
344/// tokens (they are, for our fixed security headers).
345fn static_header_layer(
346    name: &'static str,
347    value: &'static str,
348) -> SetResponseHeaderLayer<header::HeaderValue> {
349    SetResponseHeaderLayer::overriding(
350        header::HeaderName::from_static(name),
351        header::HeaderValue::from_static(value),
352    )
353}
354
355// ---------------------------------------------------------------------------
356// Per-IP rate limiting (token bucket, self-contained — no extra crate)
357// ---------------------------------------------------------------------------
358
359/// The abuse-prone paths the rate limiter guards (429 over the limit): the OAuth
360/// kick-off and callback, the invite redeem, logout, the mutating write
361/// endpoints, and mark-read/star/mark-all. Plain read-only navigation is
362/// intentionally *not* limited.
363///
364/// The criterion is **does this path make an outbound request**, not "does it
365/// mutate" — the two diverge, and every miss so far has been on the outbound
366/// side. This is an allowlist a new route has to be added to by hand, which is
367/// exactly why it has now been missed three times: `/saved/` (fixed), then
368/// `/oauth/callback` and `/logout`. The callback was the bad one — it is the
369/// only path here reachable with no session at all.
370///
371/// Known and deliberate gaps: `GET /`, `GET /manage` and `GET /opml/export` each
372/// make PDS calls but are ordinary authenticated navigation, and throttling them
373/// would degrade normal reading. They are bounded by needing a valid session.
374fn is_rate_limited_path(path: &str, method: &axum::http::Method) -> bool {
375    use axum::http::Method;
376    // `/claim` is a GET (a link the bot posts), but it consumes a reservation and
377    // a claim token in a public URL is grabbable, so it MUST be per-IP limited
378    // like the other abuse-prone entry points — not just `/login`.
379    // `/oauth/callback` is a GET, is UNAUTHENTICATED, and every hit performs a
380    // real outbound round-trip — a sidecar `resolve_session` or a full token
381    // exchange against a PDS. Anyone could spend one outbound request per hit.
382    // It is the only entry point here that needs no session at all.
383    if method != Method::POST
384        && !(method == Method::GET
385            && (path == "/login" || path == "/claim" || path == "/oauth/callback"))
386    {
387        return false;
388    }
389    match path {
390        // `/logout` and `/oauth/callback` are here because they make outbound
391        // calls, not because they mutate: logout revokes at the PDS (up to two
392        // round-trips) and the callback exchanges a code. The list is by
393        // *network cost*, which is what the limiter is actually for.
394        "/login" | "/claim" | "/oauth/callback" | "/logout" | "/beta/redeem" | "/subscriptions"
395        | "/opml" | "/read-all" | "/admin/invites" | "/bot/claims" | "/account/delete"
396        | "/folders" => true,
397        // Every per-record subscription/folder mutation (delete/rename) and the
398        // star/mark-read taps make a sidecar/PDS round-trip, so limit them too.
399        p => {
400            (p.starts_with("/entries/") && (p.ends_with("/read") || p.ends_with("/star")))
401                // Unsaving makes a DPoP-signed deleteRecord round-trip to the
402                // PDS, which is exactly the reason the neighbours above are
403                // limited. It was added as a new route and not added here.
404                || p.starts_with("/saved/")
405                || p.starts_with("/subscriptions/")
406                || p.starts_with("/folders/")
407        }
408    }
409}
410
411/// Middleware state for [`rate_limit`]: the shared limiter plus the trusted
412/// client-IP header (if any). Cloned into every request; both fields are cheap.
413#[derive(Clone)]
414struct RateLimitState {
415    limiter: RateLimiter,
416    /// The lowercased proxy header the operator trusts for the client IP, or
417    /// `None` to trust only the socket peer. See [`client_ip`].
418    trusted_header: Option<String>,
419}
420
421/// A tiny per-IP token-bucket rate limiter. Each IP gets [`RATE_BURST`] tokens
422/// that refill at [`RATE_REFILL_PER_SEC`]/sec; a request costs one token and is
423/// rejected (429) when the bucket is empty. Self-contained (no `tower_governor`
424/// dependency → no network fetch at build, deterministic offline CI).
425#[derive(Clone)]
426struct RateLimiter {
427    inner: std::sync::Arc<Mutex<RateLimiterState>>,
428}
429
430/// The limiter's shared state: the buckets plus when they were last swept.
431struct RateLimiterState {
432    buckets: HashMap<IpAddr, Bucket>,
433    last_sweep: Instant,
434}
435
436/// One IP's token bucket: a fractional token count + the last-refill instant.
437struct Bucket {
438    tokens: f64,
439    last: Instant,
440}
441
442/// Burst capacity per IP — how many requests can arrive back-to-back.
443const RATE_BURST: f64 = 20.0;
444/// Steady-state refill rate (tokens/sec) once the burst is spent.
445const RATE_REFILL_PER_SEC: f64 = 1.0;
446/// Evict idle buckets older than this so the map can't grow unbounded.
447const RATE_IDLE_EVICT: Duration = Duration::from_secs(3600);
448
449/// How often the idle sweep may actually run.
450///
451/// The sweep used to run on EVERY guarded request — an O(n) scan of the whole
452/// map to find entries that, by construction, can only age out on an hour
453/// boundary. `GET /login` and `GET /claim` are guarded and unauthenticated, so
454/// under any volume of distinct source IPs the server spent its single shared
455/// core re-walking a map whose contents had not changed. Once a minute is
456/// plenty: it bounds bucket lifetime at `RATE_IDLE_EVICT + RATE_SWEEP_EVERY`.
457const RATE_SWEEP_EVERY: Duration = Duration::from_secs(60);
458
459/// Most buckets kept. At roughly 100 bytes each this is ~1 MB — a bound, not a
460/// target, sized so ordinary traffic never reaches it.
461///
462/// The idle eviction above was the only bound, and it is a TIME bound, which
463/// says nothing about how many distinct IPs can arrive inside one hour.
464/// `net.rs` bounds the equivalent structure by count (`MAX_PINNED_CLIENTS`);
465/// this one did not.
466const MAX_RATE_BUCKETS: usize = 10_000;
467
468/// When the cap is hit, evict down to this fraction of it rather than removing
469/// a single entry — so the O(n) eviction happens once per `cap/8` requests
470/// instead of once per request while the map sits full.
471const RATE_EVICT_DOWN_TO: usize = MAX_RATE_BUCKETS * 7 / 8;
472
473impl RateLimiter {
474    /// A fresh, shared limiter (cloned into the middleware state).
475    fn shared() -> Self {
476        Self {
477            inner: std::sync::Arc::new(Mutex::new(RateLimiterState {
478                buckets: HashMap::new(),
479                last_sweep: Instant::now(),
480            })),
481        }
482    }
483
484    /// Charge one token for `ip`; returns `true` if allowed, `false` if the
485    /// bucket is empty (→ 429).
486    fn check(&self, ip: IpAddr) -> bool {
487        self.check_at(ip, Instant::now())
488    }
489
490    /// [`check`](Self::check) with the clock injected, so the sweep and eviction
491    /// paths below are reachable in a test without sleeping through an hour.
492    fn check_at(&self, ip: IpAddr, now: Instant) -> bool {
493        let mut state = match self.inner.lock() {
494            Ok(m) => m,
495            // A poisoned lock shouldn't take the site down — fail open.
496            Err(p) => p.into_inner(),
497        };
498
499        // Idle sweep, at most once per `RATE_SWEEP_EVERY`.
500        if now.duration_since(state.last_sweep) >= RATE_SWEEP_EVERY {
501            state
502                .buckets
503                .retain(|_, b| now.duration_since(b.last) < RATE_IDLE_EVICT);
504            state.last_sweep = now;
505        }
506
507        // Hard size bound, independent of the time bound above.
508        //
509        // Evicting LEAST-RECENTLY-USED is what makes this safe to do at all. An
510        // attacker cannot use eviction to clear their OWN throttled bucket: that
511        // bucket is by definition the most recently touched, so it is the last
512        // thing this removes. Going quiet long enough to become the oldest entry
513        // is exactly what the refill already grants for free.
514        if state.buckets.len() >= MAX_RATE_BUCKETS && !state.buckets.contains_key(&ip) {
515            let mut by_age: Vec<(IpAddr, Instant)> =
516                state.buckets.iter().map(|(k, b)| (*k, b.last)).collect();
517            by_age.sort_unstable_by_key(|(_, last)| *last);
518            for (victim, _) in by_age
519                .into_iter()
520                .take(state.buckets.len().saturating_sub(RATE_EVICT_DOWN_TO))
521            {
522                state.buckets.remove(&victim);
523            }
524            warn!(
525                buckets = state.buckets.len(),
526                "rate-limit bucket cap reached; evicted the least recently seen clients"
527            );
528        }
529
530        let bucket = state.buckets.entry(ip).or_insert(Bucket {
531            tokens: RATE_BURST,
532            last: now,
533        });
534        let elapsed = now.duration_since(bucket.last).as_secs_f64();
535        bucket.tokens = (bucket.tokens + elapsed * RATE_REFILL_PER_SEC).min(RATE_BURST);
536        bucket.last = now;
537        if bucket.tokens >= 1.0 {
538            bucket.tokens -= 1.0;
539            true
540        } else {
541            false
542        }
543    }
544}
545
546/// The **trusted** client IP for a request.
547///
548/// Security: a naive limiter that trusts the *left-most* `X-Forwarded-For` hop
549/// is fully bypassable — the left-most value is attacker-supplied (any client
550/// can send `X-Forwarded-For: <random>`), so each forged value lands in a fresh
551/// bucket and the per-IP limit never bites. We therefore derive the IP only from
552/// a source the operator controls:
553///
554/// * If `trusted_header` is configured (e.g. `Fly-Client-IP`,
555///   `CF-Connecting-IP`), we read the client IP from THAT header only — it is
556///   set by the proxy we run in front and overwrites any client-supplied copy.
557///   We take the LAST value if the header happens to be a comma list (the hop
558///   the trusted proxy appended), which is also the correct read for a
559///   right-most-`X-Forwarded-For` deployment where the operator points
560///   `trusted_header` at `x-forwarded-for`.
561/// * Otherwise we ignore all forwarding headers and use the socket peer
562///   (`ConnectInfo`) — correct for a direct bind with no proxy.
563///
564/// Returns `None` only when neither source yields a parseable IP (the limiter
565/// then fails open for that one request).
566fn client_ip(
567    headers: &HeaderMap,
568    conn: Option<&SocketAddr>,
569    trusted_header: Option<&str>,
570) -> Option<IpAddr> {
571    if let Some(name) = trusted_header {
572        if let Some(raw) = headers.get(name).and_then(|v| v.to_str().ok()) {
573            // Right-most hop is the one the trusted proxy appended; earlier
574            // entries may be client-forged, so never trust the left-most.
575            if let Some(last) = raw.split(',').next_back() {
576                if let Ok(ip) = last.trim().parse::<IpAddr>() {
577                    return Some(ip);
578                }
579            }
580        }
581        // Trusted header absent/unparseable → fall through to the socket peer.
582    }
583    conn.map(|s| s.ip())
584}
585
586/// Rate-limit middleware: 429 on the abuse-prone paths once an IP's bucket is
587/// empty; every other request (and every non-guarded path) passes through. The
588/// peer `SocketAddr` is read from the request extension `ConnectInfo` sets (via
589/// `into_make_service_with_connect_info`), preferring `X-Forwarded-For`.
590async fn rate_limit(
591    State(rl): State<RateLimitState>,
592    req: axum::extract::Request,
593    next: Next,
594) -> Response {
595    let path = req.uri().path().to_string();
596    let method = req.method().clone();
597    if is_rate_limited_path(&path, &method) {
598        let conn = req
599            .extensions()
600            .get::<ConnectInfo<SocketAddr>>()
601            .map(|c| c.0);
602        let ip = client_ip(req.headers(), conn.as_ref(), rl.trusted_header.as_deref());
603        // Deliberately fail OPEN when no client IP is derivable (no trusted
604        // header / no socket peer): there is no per-IP key to enforce, and a
605        // blanket 429 would self-DoS every guarded path (incl. /login). This is
606        // safe precisely because we never key on an attacker-forged XFF — see
607        // `rate_limit_ignores_spoofed_xff_rotation`.
608        if let Some(ip) = ip {
609            if !rl.limiter.check(ip) {
610                warn!(%ip, %path, "rate limit exceeded");
611                return (
612                    StatusCode::TOO_MANY_REQUESTS,
613                    [(header::RETRY_AFTER, "1")],
614                    "rate limit exceeded\n",
615                )
616                    .into_response();
617            }
618        }
619    }
620    next.run(req).await
621}
622
623// ---------------------------------------------------------------------------
624// Cache-Control (viral / CDN vs. private authenticated views)
625// ---------------------------------------------------------------------------
626
627/// Cache-Control middleware. Emits `public, max-age=300` on the cacheable
628/// logged-out surfaces (the `/login` landing without a handle, `/about`,
629/// `/privacy`, `/terms`, and the `/static/*` assets) and `no-store` on the
630/// authenticated app pages, so a CDN /
631/// browser can hold the viral landing while never caching a signed-in user's
632/// private view. Never overrides a handler that already set Cache-Control.
633async fn cache_control(req: axum::extract::Request, next: Next) -> Response {
634    let path = req.uri().path().to_string();
635    // The logged-out landing is only cacheable when it's the bare form — a
636    // `?handle=` GET kicks off OAuth (a redirect), which must not be cached.
637    let is_login_landing = path == "/login"
638        && req.method() == axum::http::Method::GET
639        && !req.uri().query().unwrap_or("").contains("handle=");
640    let public = is_login_landing
641        || path == "/about"
642        || path == "/privacy"
643        || path == "/terms"
644        || path.starts_with("/static/");
645
646    let mut resp = next.run(req).await;
647    if resp.headers().contains_key(header::CACHE_CONTROL) {
648        return resp;
649    }
650    let value = if public {
651        "public, max-age=300"
652    } else {
653        "no-store"
654    };
655    if let Ok(hv) = header::HeaderValue::from_str(value) {
656        resp.headers_mut().insert(header::CACHE_CONTROL, hv);
657    }
658    resp
659}
660
661// ---------------------------------------------------------------------------
662// Health
663// ---------------------------------------------------------------------------
664
665/// Run `/health`'s database probe. **The single path, so a test cannot assert
666/// on a string the handler is free to ignore** — a named constant alone was not
667/// enough: the test read the constant while the handler passed `query_scalar`
668/// whatever it liked, so degrading the real call to `SELECT 1` shipped green.
669async fn health_db_probe(pool: &store::Pool) -> Result<Option<i64>, sqlx::Error> {
670    sqlx::query_scalar::<_, i64>(HEALTH_DB_PROBE_SQL)
671        .fetch_optional(pool)
672        .await
673}
674
675/// The statement `/health` uses to prove the database is readable.
676///
677/// **A named constant so the test can assert on the query that actually runs.**
678/// `the_health_probe_opens_a_real_table` used to `EXPLAIN` a hand-typed copy of
679/// this string, so degrading the real probe to `SELECT 1` — which opens no page
680/// and therefore cannot detect a broken database — left the suite green.
681const HEALTH_DB_PROBE_SQL: &str = "SELECT 1 FROM feeds LIMIT 1";
682
683/// How long `/health` will wait for its database ping before calling it broken.
684///
685/// Under `fly.toml`'s 3 s check timeout, so a hung pool produces a 503 this
686/// handler chose rather than a timeout Fly inferred — the difference between a
687/// log line that says why and one that says nothing.
688const HEALTH_DB_TIMEOUT: Duration = Duration::from_secs(2);
689
690/// Floor for the poll-heartbeat staleness threshold. **Reported, never fatal** —
691/// see the handler for why.
692///
693/// The threshold itself is derived from the configured tick
694/// ([`health_tick_stale_secs`]): hardcoding 15 minutes meant an operator who
695/// raised `FEATHERREADER_POLL_TICK_SECS` above 900 got a permanent `poller:
696/// stale` in the body the deployment docs now tell them to alert on.
697const HEALTH_TICK_STALE_FLOOR_SECS: i64 = 15 * 60;
698
699/// How long without a completed tick before the poller reads as stale: several
700/// tick intervals, floored, so a normally-paced loop never trips it and a
701/// genuinely wedged one always does.
702fn health_tick_stale_secs(tick: Duration) -> i64 {
703    let tick = i64::try_from(tick.as_secs()).unwrap_or(i64::MAX);
704    tick.saturating_mul(5).max(HEALTH_TICK_STALE_FLOOR_SECS)
705}
706
707/// The poll tick this instance is configured for. Read from the same env var
708/// `scheduler.rs` reads, because the scheduler lives in the binary crate and the
709/// handler cannot see its constants.
710fn configured_poll_tick() -> Duration {
711    std::env::var("FEATHERREADER_POLL_TICK_SECS")
712        .ok()
713        .and_then(|v| v.trim().parse::<u64>().ok())
714        .filter(|s| *s > 0)
715        .map_or(DEFAULT_POLL_TICK_SECS, Duration::from_secs)
716}
717
718/// Mirrors `scheduler::DEFAULT_POLL_TICK`, which lives in the BINARY crate and
719/// so cannot be imported here. Duplicated deliberately and named, rather than
720/// left as a bare `60` inside the parse chain, so the drift is at least visible
721/// if the scheduler's value ever moves.
722const DEFAULT_POLL_TICK_SECS: Duration = Duration::from_secs(60);
723
724/// Grace period after boot before a poller that has never ticked is called
725/// `stale` rather than `not-yet-ticked`.
726///
727/// Without this the two are indistinguishable forever, which matters precisely
728/// in the case the startup delays were added for: in a crash loop with 30 s+ boot
729/// cycles the poller never reaches its first tick, so `/health` reported the
730/// benign `not-yet-ticked` on every single probe and the heartbeat could not
731/// detect the failure mode it exists for. `run_poller` returning early — a failed
732/// HTTP client build — has the same shape and was equally invisible.
733///
734/// Sized off the poller's own startup delay plus its tick, with slack.
735const HEALTH_FIRST_TICK_GRACE_SECS: i64 = 5 * 60;
736
737/// `GET /health` — does this process still work, and what are its loops doing?
738///
739/// This used to return a constant string, touching no database, no pool and no
740/// scheduler state — while being the ONLY automated signal in `fly.toml`, whose
741/// sole other failure detector is a child process exiting. It proved the HTTP
742/// listener was up and nothing else.
743///
744/// **What can fail the check: the database, and only the database.** A process
745/// that cannot reach its store serves nothing, so a restart is the right
746/// response and this returns 503. The probe is a read (`SELECT 1`), which in WAL
747/// mode is not blocked by any writer — so the retention sweep, the poller and a
748/// login burst cannot make this flap. That property is the reason it is a read
749/// and not, say, a write canary.
750///
751/// **What is reported but never fails the check: everything else.** A stale poll
752/// heartbeat, a watermark pause, a missing OAuth runtime — all real problems,
753/// and none of them a reason to stop serving.
754///
755/// That last clause is the whole justification, and it is NOT the one this
756/// comment used to give. It said "Fly restarts on a failed check", which is
757/// false — verified against Fly's own docs, which state it three times: *"your
758/// Machines won't automatically restart or stop due to failing their health
759/// checks"*. A failing `[[http_service.checks]]` check makes Fly Proxy stop
760/// ROUTING to the Machine. Nothing restarts it. That capability existed on Apps
761/// V1 (`restart_limit`) and has no successor on Machines.
762///
763/// The corrected model makes the conclusion stronger, not weaker. With one
764/// Machine there is no healthy peer to shift traffic to, so a 503 here is not a
765/// failover — it is a total outage that lasts exactly as long as the condition,
766/// and it also fails a `fly deploy` (rolling strategy, no auto-rollback). So the
767/// question the status code answers is not "would a restart fix this" but **"can
768/// this process still serve a useful request at all"**. A stale poller can. A
769/// database it cannot read cannot.
770///
771/// Re-registration is automatic: the proxy keeps probing and routes again the
772/// moment the check passes. That is what makes a 503 recoverable without
773/// intervention — not a restart, which never comes.
774///
775/// The body is machine facts only — no user counts, no DIDs, no feed URLs — so
776/// it is publishable on the same terms as `/stats`. It is also the non-session
777/// diagnostic for an OAuth outage: when nobody can log in, `/admin/metrics`
778/// (which needs a live admin session) is exactly as unreachable as the thing it
779/// would diagnose, while this is reachable with `curl`.
780async fn health(State(state): State<AppState>) -> Response {
781    let now = chrono::Utc::now().timestamp();
782    let rh = &state.runtime_health;
783
784    use crate::runtime_health::DbProbe;
785    let db = match rh.begin_db_probe() {
786        // A probe is already in flight; report its predecessor rather than
787        // starting a second one. See `RuntimeHealth::begin_db_probe`.
788        Err(borrowed) => borrowed,
789        Ok(probe) => {
790            // **Spawned, so the probe cannot be cancelled by the caller.**
791            //
792            // Axum drops the handler future when a client disconnects. With the
793            // probe inline, that dropped it mid-flight and released the claim
794            // WITHOUT recording a verdict — which let an unauthenticated caller
795            // manufacture the no-verdict state on demand and freeze what every
796            // other caller, Fly's check included, reads. Running it detached
797            // means the verdict is always recorded and the claim is always
798            // released after it.
799            let pool = state.db.clone();
800            let task = tokio::spawn(async move {
801                // **`SELECT 1` was not a database probe.** It compiles to
802                // `Init/Integer/ResultRow/Halt` — there is no `OpenRead`, so it
803                // never touches a b-tree, never reads a page, and never consults
804                // the file. Against a corrupted database it returns success
805                // while every real query returns SQLITE_CORRUPT. Reading one row
806                // from a real table costs the same and actually proves what the
807                // check claims. `LIMIT 1` keeps it to a single page; an empty
808                // table still opens the b-tree root, which is the part that
809                // matters.
810                let verdict =
811                    match tokio::time::timeout(HEALTH_DB_TIMEOUT, health_db_probe(&pool)).await {
812                        Ok(Ok(_)) => DbProbe::Ok,
813                        // Coarse, not the raw error. An unauthenticated caller
814                        // learning exactly which failure it hit is an
815                        // attack-progress oracle; the detail belongs in the log,
816                        // which gets it here.
817                        Ok(Err(err)) => {
818                            warn!(%err, "health: database probe failed");
819                            DbProbe::Failed("unavailable".to_string())
820                        }
821                        Err(_) => {
822                            warn!(
823                                timeout_s = HEALTH_DB_TIMEOUT.as_secs(),
824                                "health: database probe timed out (pool exhausted?)"
825                            );
826                            DbProbe::Failed("timeout".to_string())
827                        }
828                    };
829                probe.record(verdict.clone());
830                verdict
831            });
832            // A panicking task drops the guard, which releases the claim without
833            // a verdict — the only remaining path to that state, and not one a
834            // caller can drive.
835            task.await.unwrap_or(DbProbe::Unknown)
836        }
837    };
838
839    let uptime = rh.uptime_secs(now);
840    let poller = if !rh.schedulers_enabled() {
841        // Not a fault. Dev runs and the seam tests disable the loops on purpose,
842        // and reporting that as "stale" would be a false alarm on every one.
843        "disabled".to_string()
844    } else {
845        match rh.secs_since_poll_tick(now) {
846            // "Never ticked" is benign right after boot and alarming well after
847            // it — so it is read against UPTIME, not left permanently benign.
848            None => match uptime {
849                Some(up) if up > HEALTH_FIRST_TICK_GRACE_SECS => {
850                    format!("stale never-ticked {up}s")
851                }
852                _ => "not-yet-ticked".to_string(),
853            },
854            Some(secs) if secs > health_tick_stale_secs(configured_poll_tick()) => {
855                format!("stale {secs}s")
856            }
857            Some(secs) => format!("ok {secs}s"),
858        }
859    };
860
861    // **Only a MEASURED failure fails the check.**
862    //
863    // `Unknown` means no probe has completed — a concurrent request arrived
864    // before the first one finished, or a previous owner was cancelled before
865    // recording. It is reported and returns 200, because an unmeasured database
866    // is not evidence of a broken one, and this endpoint is reachable by
867    // unauthenticated callers who can manufacture that state. Treating it as a
868    // failure handed them a lever on the only signal the platform acts on.
869    let mut body = String::new();
870    let status = match &db {
871        DbProbe::Ok => {
872            body.push_str(&format!("ok featherreader/{VERSION}\n"));
873            body.push_str("db: ok\n");
874            StatusCode::OK
875        }
876        // **Not `ok`.** The first token is the state, and this one is neither
877        // healthy nor failed. It used to print a line byte-identical to the
878        // healthy branch, which mattered because `fly.toml` tells operators to
879        // alert on the BODY for everything the status code deliberately ignores
880        // — so a monitor keying on `^ok` read green in exactly the state this
881        // enum exists to make visible.
882        DbProbe::Unknown => {
883            body.push_str(&format!("unknown featherreader/{VERSION}\n"));
884            body.push_str("db: unknown (no probe has completed yet)\n");
885            StatusCode::OK
886        }
887        DbProbe::Failed(why) => {
888            body.push_str(&format!("FAIL featherreader/{VERSION}\n"));
889            body.push_str(&format!("db: {why}\n"));
890            StatusCode::SERVICE_UNAVAILABLE
891        }
892    };
893    // Uptime answers the first question anyone asks about a container under a
894    // supervisor that tears the machine down whenever a child exits: is this
895    // thing restarting? Nothing else on any surface could tell you.
896    body.push_str(&format!(
897        "uptime: {}\n",
898        match uptime {
899            Some(secs) => format!("{secs}s"),
900            None => "unknown".to_string(),
901        }
902    ));
903    body.push_str(&format!("poller: {poller}\n"));
904    body.push_str(&format!(
905        "polling-paused: {}\n",
906        if rh.watermark_paused() { "yes" } else { "no" }
907    ));
908    // Deliberately NOT the measured database size. `/health` is the one path
909    // exempted from the Caddy origin lock, so it answers direct hits to the Fly
910    // IP that never passed Cloudflare — which caps what belongs here at the
911    // class of facts `/stats` already publishes to anyone. "Polling is paused"
912    // is that; the exact byte count is a precise internal number that adds
913    // nothing an operator cannot get from `/stats` or the logs.
914    body.push_str(&format!(
915        "backend: {}\n",
916        state.config.repo_backend.as_str()
917    ));
918    body.push_str(&format!(
919        "oauth-runtime: {}\n",
920        if state.oauth.is_some() {
921            "built"
922        } else {
923            "absent"
924        }
925    ));
926
927    // Never cached: a stale health response is worse than none, and Cloudflare
928    // sits in front of this.
929    let mut resp = (status, body).into_response();
930    if let Ok(hv) = header::HeaderValue::from_str("no-store") {
931        resp.headers_mut().insert(header::CACHE_CONTROL, hv);
932    }
933    resp
934}
935
936/// `GET /about` — the public-experiment page: the full disclaimer (experimental,
937/// no SLA, may pause anytime), the OSS / self-host pitch, and the tip link.
938/// Readable whether or not a session exists.
939///
940/// Optionally carries one quiet line about network adoption
941/// (`design/NETWORK-SPEC.md` §4.4). With `FEATHERREADER_SHOW_ADOPTION` off — the
942/// default — the handler issues **zero** queries and the page is byte-identical
943/// to what it was before the probe existed.
944async fn about(State(state): State<AppState>) -> Response {
945    let adoption = if state.config.show_adoption {
946        adoption_line(&state).await
947    } else {
948        None
949    };
950    render(&AboutTemplate {
951        version: VERSION,
952        repo_url: REPO_URL,
953        kofi_url: KOFI_URL,
954        adoption,
955    })
956}
957
958/// `POST /saved/:rkey/delete` — remove a saved record that has no local entry.
959///
960/// The normal star toggle is keyed on an entry id, which a PDS-only saved row
961/// does not have. This deletes the record straight from the repo by its rkey,
962/// and then clears any LOCAL star for the same article.
963///
964/// That second step is not belt-and-braces. "Has no local entry" is how the
965/// starred view classifies a record, and it decides that through `sub_ref` — so
966/// an article that really is cached, and really is starred, lands here whenever
967/// the reader has unsubscribed from its feed. Deleting only the record left
968/// `entry_state.starred = 1` behind: invisible, because the starred list is
969/// `sub_ref`-scoped too, until a resubscribe brought the star back with nothing
970/// in the PDS backing it. A reader who clicks "remove" gets it removed from both
971/// places it lives.
972async fn unsave_record(
973    State(state): State<AppState>,
974    headers: HeaderMap,
975    Path(rkey): Path<String>,
976) -> Response {
977    let Some(did) = current_did(&state, &headers).await else {
978        return (StatusCode::UNAUTHORIZED, "sign in first\n").into_response();
979    };
980
981    // Read the record's identity BEFORE deleting it — afterwards there is
982    // nothing left to learn it from. Best-effort: a failure here must not block
983    // the deletion the reader actually asked for, so it degrades to the old
984    // behaviour (record gone, local star possibly stale) and says so.
985    let identity = match state.repo().list_saved(&did).await {
986        Ok(records) => records
987            .into_iter()
988            .find(|(k, _)| *k == rkey)
989            .map(|(_, rec)| (rec.url, rec.entry_id)),
990        Err(err) => {
991            warn!(%err, %did, %rkey, "could not read the saved record before deleting it; \
992                                      a local star for the same article may survive");
993            None
994        }
995    };
996
997    match state.repo().remove_saved(&did, &rkey).await {
998        Ok(()) => info!(%did, %rkey, "removed a saved record with no cached entry"),
999        Err(err) => {
1000            warn!(%err, %did, %rkey, "could not remove the saved record");
1001            return (StatusCode::BAD_GATEWAY, "could not remove that item\n").into_response();
1002        }
1003    }
1004
1005    // Deliberately AFTER the delete: the PDS is the source of truth for what was
1006    // saved, so clearing the local star before knowing the record is gone would
1007    // be the desync in the other direction.
1008    if let Some((url, guid)) = identity {
1009        match store::clear_star_by_identity(&state.db, &did, Some(&url), guid.as_deref()).await {
1010            Ok(0) => {}
1011            Ok(n) => {
1012                info!(%did, %rkey, cleared = n, "cleared the local star for an unsaved record")
1013            }
1014            Err(err) => warn!(%err, %did, %rkey, "could not clear the local star after unsaving"),
1015        }
1016    }
1017    // htmx swaps the row out; a plain form post goes back to the starred list.
1018    if is_htmx(&headers) {
1019        return (StatusCode::OK, "").into_response();
1020    }
1021    Redirect::to("/?view=starred").into_response()
1022}
1023
1024/// What the poller is doing, as one word for `/stats`.
1025///
1026/// **Parity with `/health` is the point.** `polling_paused` alone reported
1027/// "running" for three different states including the two where nothing polls,
1028/// on the page added to answer exactly that. The first attempt at fixing it
1029/// added `off` and `starting` and claimed parity — but left out `stale`, so a
1030/// poll loop that ticked once at boot and then WEDGED still read as running.
1031/// That is the wedged-loop case `/health`'s heartbeat exists for, and the
1032/// original finding's exact shape surviving its own fix.
1033///
1034/// Shares the staleness threshold with `/health` rather than picking its own, so
1035/// the two pages cannot disagree about what "stale" means.
1036fn fetching_state(rh: &crate::runtime_health::RuntimeHealth, now_unix: i64) -> &'static str {
1037    if !rh.schedulers_enabled() {
1038        return "off";
1039    }
1040    // Checked before the pause: a wedged poller cannot clear a pause either, so
1041    // reporting "paused" would name the symptom and hide the cause.
1042    match rh.secs_since_poll_tick(now_unix) {
1043        None => {
1044            // Never ticked. Benign at boot, a dead loop long after — read
1045            // against uptime, exactly as `/health` does.
1046            match rh.uptime_secs(now_unix) {
1047                Some(up) if up > HEALTH_FIRST_TICK_GRACE_SECS => "stale",
1048                _ => "starting",
1049            }
1050        }
1051        Some(secs) if secs > health_tick_stale_secs(configured_poll_tick()) => "stale",
1052        _ if rh.watermark_paused() => "paused",
1053        _ => "running",
1054    }
1055}
1056
1057/// `GET /stats` — public poll health.
1058async fn stats(State(state): State<AppState>) -> Response {
1059    let now = chrono::Utc::now();
1060    let health = match store::poll_health(
1061        &state.db,
1062        &now.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
1063        &(now - chrono::Duration::hours(1)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
1064    )
1065    .await
1066    {
1067        Ok(health) => health,
1068        Err(err) => {
1069            warn!(%err, "could not compute poll health");
1070            return (StatusCode::INTERNAL_SERVER_ERROR, "stats unavailable\n").into_response();
1071        }
1072    };
1073
1074    // Percentage of a zero-feed instance is 100, not a divide-by-zero: a fresh
1075    // instance is not behind on anything.
1076    let polled_pct = if health.feeds_tracked == 0 {
1077        100
1078    } else {
1079        health.polled_last_hour * 100 / health.feeds_tracked
1080    };
1081
1082    render(&StatsTemplate {
1083        version: VERSION,
1084        repo_url: REPO_URL,
1085        kofi_url: KOFI_URL,
1086        feeds_tracked: health.feeds_tracked,
1087        polled_last_hour: health.polled_last_hour,
1088        polled_pct,
1089        overdue: health.overdue,
1090        last_poll: humanise_ago(health.last_poll_secs_ago),
1091        oldest_poll: if health.never_polled > 0 {
1092            "never".to_string()
1093        } else {
1094            humanise_ago(health.oldest_poll_secs_ago)
1095        },
1096        never_polled: health.never_polled,
1097        poll_interval_mins: state.config.poll_interval.as_secs() as i64 / 60,
1098        // **The two states that actually stop feeds updating.**
1099        //
1100        // Neither was visible anywhere. `overdue` and `polled_last_hour` move in
1101        // both and distinguish neither — and `overdue` moves the WRONG WAY for
1102        // backoff, since backoff is applied by pushing `next_poll` forward, so a
1103        // feed failing every fetch drops out of the backlog and makes the page
1104        // read healthier. Both of these are machine facts with no per-feed
1105        // detail, so they sit inside the page's stated contract.
1106        in_backoff: health.in_backoff,
1107        badly_broken: health.badly_broken,
1108        failure_kinds: health.failure_kinds,
1109        fetching: fetching_state(&state.runtime_health, now.timestamp()),
1110    })
1111}
1112
1113/// "3h 11m ago", or "never" when there has been no poll at all.
1114///
1115/// `None` must not render as `0` — on a fresh instance that would read as
1116/// "polled just now", which is the opposite of the truth.
1117fn humanise_ago(secs: Option<i64>) -> String {
1118    let Some(secs) = secs else {
1119        return "never".to_string();
1120    };
1121    match secs {
1122        s if s < 60 => format!("{s}s ago"),
1123        s if s < 3600 => format!("{}m ago", s / 60),
1124        s => format!("{}h {}m ago", s / 3600, (s % 3600) / 60),
1125    }
1126}
1127
1128/// The `/about` adoption line's data, or `None` (no successful probe yet, an
1129/// observation of zero, or a store failure).
1130///
1131/// A read failure degrades to `None` plus a `warn!` rather than propagating: the
1132/// probe is never allowed to affect the reader, and that rule applies at the
1133/// display end too — a locked or corrupt DB costs the About page one log line,
1134/// not a 500.
1135async fn adoption_line(state: &AppState) -> Option<AdoptionLine> {
1136    match store::latest_network_stat(&state.db, store::ADOPTION_STAT_KEY).await {
1137        // A legitimate zero renders nothing rather than a sad "0 accounts".
1138        Ok(Some(stat)) if stat.value > 0 => Some(AdoptionLine {
1139            repos: stat.value,
1140            truncated: stat.truncated,
1141            observed_on: stat
1142                .observed_at
1143                .split('T')
1144                .next()
1145                .unwrap_or_default()
1146                .to_string(),
1147        }),
1148        Ok(_) => None,
1149        Err(err) => {
1150            warn!(%err, "about: adoption stat read failed; omitting the line");
1151            None
1152        }
1153    }
1154}
1155
1156/// `GET /privacy` — the plain-language privacy page: no account/tracking, data
1157/// lives in the user's PDS, what the server caches, and the session-token
1158/// handling. A static render; readable whether or not a session exists.
1159async fn privacy() -> Response {
1160    render(&PrivacyTemplate {
1161        version: VERSION,
1162        repo_url: REPO_URL,
1163        kofi_url: KOFI_URL,
1164    })
1165}
1166
1167/// `GET /terms` — the terms of use: the experimental / as-is disclaimer,
1168/// acceptable use, the AGPL/self-host note, and the liability limitation. A
1169/// static render; readable whether or not a session exists.
1170async fn terms() -> Response {
1171    render(&TermsTemplate {
1172        version: VERSION,
1173        repo_url: REPO_URL,
1174        kofi_url: KOFI_URL,
1175    })
1176}
1177
1178// ---------------------------------------------------------------------------
1179// View models
1180// ---------------------------------------------------------------------------
1181
1182/// A feed as shown in the sidebar (title + its unread count + a stable scope key
1183/// and the PDS subscription rkey for management actions).
1184struct FeedView {
1185    /// PDS subscription rkey — addresses the record for rename/unsubscribe.
1186    rkey: String,
1187    /// Canonical feed URL — the sidebar filter key (`?feed=<url>`).
1188    url: String,
1189    title: String,
1190    unread: i64,
1191    /// Whether this feed is the currently-selected scope.
1192    selected: bool,
1193    /// The feed's current folder `at://` URI (from its subscription record), or
1194    /// `None` if un-foldered. Drives the pre-selected `<option>` in the manage
1195    /// rename row so an untouched folder dropdown does not silently un-folder the
1196    /// feed on save.
1197    folder: Option<String>,
1198}
1199
1200/// A folder grouping in the sidebar, sourced from the PDS `folder` records.
1201struct FolderView {
1202    /// PDS folder rkey — addresses the record for rename/delete.
1203    rkey: String,
1204    /// The folder's `at://` URI — the sidebar filter key (`?folder=<uri>`).
1205    uri: String,
1206    name: String,
1207    feeds: Vec<FeedView>,
1208    /// Whether this folder is the currently-selected scope.
1209    selected: bool,
1210}
1211
1212/// One entry as shown in the article list / after an htmx swap.
1213struct EntryRow {
1214    id: i64,
1215    title: String,
1216    feed_title: String,
1217    published: String,
1218    read: bool,
1219    starred: bool,
1220    /// The reader link href, already carrying the scope/view query so opening an
1221    /// entry and paging back stays within the list it came from.
1222    link: SafeLink,
1223    /// Whether the article itself is in this instance's cache.
1224    ///
1225    /// `false` for a saved record that exists in the reader's PDS but whose
1226    /// entry was never cached here — starred in another atproto reader, or
1227    /// starred here and since evicted. There is no local row, so the row has no
1228    /// usable `id`: it links straight out to the article and carries no
1229    /// mark-read control, because there is nothing local to mark.
1230    cached: bool,
1231    /// The PDS record key, for un-saving a row that has no local entry.
1232    rkey: String,
1233}
1234
1235/// A folder as an option in the "move feed to folder" select.
1236struct FolderOption {
1237    uri: String,
1238    name: String,
1239}
1240
1241/// The shared navigation "rail" model: the same DOM element is the
1242/// mobile drawer and the desktop sidebar, so every chrome page (list / reader /
1243/// manage) renders it from this one struct. Feed management lives on `/manage`,
1244/// not here — the rail is navigation only.
1245struct Nav {
1246    /// `@handle` for the identity chip (falls back to the DID's tail).
1247    handle: String,
1248    /// Two-letter avatar initials for the identity chip.
1249    avatar: String,
1250    /// The active filter: `"unread" | "all" | "starred"` (drives `aria-current`).
1251    view: String,
1252    /// The scope query suffix (`feed=…` / `folder=…`) carried onto filter links,
1253    /// empty for the unscoped "everything" views.
1254    scope_qs: String,
1255    /// Folders (each with its feeds) then un-foldered feeds, for the rail lists.
1256    /// Per-feed `selected` flags drive the rail's feed `aria-current`.
1257    folders: Vec<FolderView>,
1258    loose_feeds: Vec<FeedView>,
1259    /// Whether the "Manage feeds" rail tool is the current page.
1260    manage_active: bool,
1261}
1262
1263/// The reader index (`GET /`).
1264#[derive(Template)]
1265#[template(path = "index.html")]
1266struct IndexTemplate {
1267    version: &'static str,
1268    repo_url: &'static str,
1269    kofi_url: &'static str,
1270    flash: String,
1271    /// The shared rail (drawer + desktop sidebar) navigation model.
1272    nav: Nav,
1273    /// The article list for the selected scope + view.
1274    entries: Vec<EntryRow>,
1275    /// The list heading (the selected view/feed/folder name).
1276    heading: String,
1277    /// Whether a feed scope is active (enables per-feed mark-all-read).
1278    feed_scope: Option<String>,
1279    /// Total CACHED entries in this scope + view across ALL pages. The count used
1280    /// to be `entries.len()`, which was the same number only because the list was
1281    /// unpaged — the thing this change exists to stop.
1282    ///
1283    /// The pager is derived from this, so it must not include the uncached PDS
1284    /// rows below: they are appended to the last page rather than paged, and
1285    /// counting them here advertised a page the clamp could never reach.
1286    total: i64,
1287    /// How many of `total` are PDS saved records the cache cannot show.
1288    ///
1289    /// A subset of `total`, not an addition to it — the heading says "N entries
1290    /// (M saved elsewhere)". An earlier version rendered "N entries, plus M",
1291    /// which double counted once `total` started including them, against an M
1292    /// that had become page-local in the same commit while the template stayed
1293    /// put.
1294    uncached_total: i64,
1295    /// 1-based current page.
1296    page: i64,
1297    /// Total pages, at least 1 (an empty list is page 1 of 1).
1298    page_count: i64,
1299    /// Link to the previous (newer) page, or `None` on the first.
1300    prev_href: Option<String>,
1301    /// Link to the next (older) page, or `None` on the last.
1302    next_href: Option<String>,
1303}
1304
1305/// The feed-management page (`GET /manage`) — subscribe / your-feeds / OPML.
1306#[derive(Template)]
1307#[template(path = "manage.html")]
1308struct ManageTemplate {
1309    version: &'static str,
1310    repo_url: &'static str,
1311    kofi_url: &'static str,
1312    flash: String,
1313    nav: Nav,
1314    /// All folders as move-targets for the subscribe folder select.
1315    folder_options: Vec<FolderOption>,
1316    /// Folders (each with feeds) + loose feeds, for the "Your feeds" list.
1317    folders: Vec<FolderView>,
1318    loose_feeds: Vec<FeedView>,
1319}
1320
1321/// The optional one-line adoption fact at the bottom of `/about`
1322/// (`design/NETWORK-SPEC.md` §4.4). `None` whenever the display flag is off, no
1323/// probe has succeeded yet, or the read failed — the line then simply does not
1324/// render.
1325struct AdoptionLine {
1326    /// Repos a relay has indexed as holding the subscription collection.
1327    repos: i64,
1328    /// The probe hit its page cap, so the copy must say "at least".
1329    truncated: bool,
1330    /// Observation date, `YYYY-MM-DD` (UTC), sliced from the stored RFC3339 stamp.
1331    observed_on: String,
1332}
1333
1334/// The public-experiment `/about` page — disclaimer + OSS pitch + tip link, plus
1335/// the optional adoption line.
1336#[derive(Template)]
1337#[template(path = "about.html")]
1338struct AboutTemplate {
1339    version: &'static str,
1340    repo_url: &'static str,
1341    kofi_url: &'static str,
1342    adoption: Option<AdoptionLine>,
1343}
1344
1345/// The public `/stats` page — is the poller keeping up?
1346///
1347/// Aggregate only, deliberately. It is published to anyone, so it carries no
1348/// user counts and no per-feed detail: a reader does not need to know how many
1349/// people use an instance or which feeds are failing. What it does answer is the
1350/// question that decides whether an instance can take more readers — whether the
1351/// poller is servicing the feeds it already has.
1352///
1353/// The counts below are aggregate machine facts, which is why they fit that
1354/// contract: "12 feeds are in backoff" names no feed and no reader, while
1355/// answering the question the page was previously unable to answer at all.
1356#[derive(Template)]
1357#[template(path = "stats.html")]
1358struct StatsTemplate {
1359    version: &'static str,
1360    repo_url: &'static str,
1361    kofi_url: &'static str,
1362    feeds_tracked: i64,
1363    polled_last_hour: i64,
1364    polled_pct: i64,
1365    overdue: i64,
1366    last_poll: String,
1367    oldest_poll: String,
1368    never_polled: i64,
1369    poll_interval_mins: i64,
1370    /// Feeds in error backoff. Invisible before, and excluded from `overdue`.
1371    in_backoff: i64,
1372    /// Of those, the ones retried hours apart rather than minutes. **Not
1373    /// "effectively dead"** — see `store::BADLY_BROKEN_ERRORS`; they recover on
1374    /// their next successful poll, and most of this instance's did.
1375    badly_broken: i64,
1376    /// Failing feeds by cause, descending — counts only, never which feed.
1377    failure_kinds: Vec<(String, i64)>,
1378    /// What the poller is actually doing: `running`, `paused` (at the size
1379    /// watermark), `starting` (no tick completed yet) or `off` (schedulers
1380    /// disabled). Three of those four used to render as "running".
1381    fetching: &'static str,
1382}
1383
1384/// The public `/privacy` page — what the server holds vs. what lives in the
1385/// user's PDS. Carries the same `repo_url`/`kofi_url`/`version` the shared
1386/// footer include needs.
1387#[derive(Template)]
1388#[template(path = "privacy.html")]
1389struct PrivacyTemplate {
1390    version: &'static str,
1391    repo_url: &'static str,
1392    kofi_url: &'static str,
1393}
1394
1395/// The public `/terms` page — the as-is / no-warranty terms of use. Carries the
1396/// same fields the shared footer include needs.
1397#[derive(Template)]
1398#[template(path = "terms.html")]
1399struct TermsTemplate {
1400    version: &'static str,
1401    repo_url: &'static str,
1402    kofi_url: &'static str,
1403}
1404
1405/// The signed-out landing page (`GET /` with no session) — the public front
1406/// door at feather-reader.com. A static render, no session required.
1407#[derive(Template)]
1408#[template(path = "landing.html")]
1409struct LandingTemplate {
1410    version: &'static str,
1411    repo_url: &'static str,
1412    crates_url: &'static str,
1413    kofi_url: &'static str,
1414}
1415
1416/// The single-entry reader view (`GET /entries/:id`).
1417#[derive(Template)]
1418#[template(path = "entry.html")]
1419struct EntryTemplate {
1420    version: &'static str,
1421    repo_url: &'static str,
1422    kofi_url: &'static str,
1423    nav: Nav,
1424    id: i64,
1425    title: String,
1426    feed_title: String,
1427    author: Option<String>,
1428    published: String,
1429    /// The entry's own link, for `entry.html`'s two `href`s.
1430    ///
1431    /// `Option<SafeLink>`, not `Option<String>`: the column it comes from holds
1432    /// a remote feed's `<link>`. Ingest scheme-checks it, but that guard is a
1433    /// long way from the `href` and holds only while every future writer to
1434    /// `entries.url` remembers to go through `feed.rs` — the same procedural
1435    /// defence that, on the saved-record row, turned out to be deletable with
1436    /// all 679 tests still green. `None` is the refusal: the template's
1437    /// no-URL branch already renders a disabled open-original button.
1438    url: Option<SafeLink>,
1439    content_html: Option<String>,
1440    read: bool,
1441    starred: bool,
1442    /// The query string to carry the reading context back to the list.
1443    back_qs: String,
1444    /// Prev/next entry ids within the current list, for keyboard/paging nav.
1445    prev_id: Option<i64>,
1446    next_id: Option<i64>,
1447    /// Rendered inline (not an out-of-band swap fragment): always `false` here.
1448    oob: bool,
1449}
1450
1451/// The htmx swap fragment for a single entry row (`entry_row.html`).
1452#[derive(Template)]
1453#[template(path = "entry_row.html")]
1454struct EntryRowTemplate {
1455    e: EntryRow,
1456}
1457
1458/// The reader's action-bar fragment (`entry_actionbar.html`) returned as an
1459/// out-of-band swap after a mark-read / star toggle FROM THE READER, so the
1460/// button's hidden value + `aria-pressed` update in place (the reader `<li>`
1461/// isn't in the DOM to swap, unlike the list view's `entry_row.html`).
1462#[derive(Template)]
1463#[template(path = "entry_actionbar.html")]
1464struct EntryActionBarTemplate {
1465    id: i64,
1466    read: bool,
1467    starred: bool,
1468    /// Emit the `hx-swap-oob` attribute: `true` for the handler's OOB response.
1469    oob: bool,
1470}
1471
1472/// The login stub (`GET /login`).
1473#[derive(Template)]
1474#[template(path = "login.html")]
1475struct LoginTemplate {
1476    repo_url: &'static str,
1477    error: String,
1478    /// A neutral/success banner (e.g. the post-delete "signed out" confirmation),
1479    /// distinct from `error`. Empty renders nothing.
1480    flash: String,
1481}
1482
1483/// The closed-beta invite-redeem page (`GET /beta/redeem`).
1484#[derive(Template)]
1485#[template(path = "beta_redeem.html")]
1486struct BetaRedeemTemplate {
1487    repo_url: &'static str,
1488    error: String,
1489    /// When true the seat cap is full: hide the form and show the "capacity
1490    /// full — try self-hosting" message instead.
1491    capacity_full: bool,
1492}
1493
1494// ---------------------------------------------------------------------------
1495// Rendering + error helpers
1496// ---------------------------------------------------------------------------
1497
1498/// Render an askama template into an HTML response, mapping a render failure to
1499/// a `500` rather than panicking (no `unwrap` in the request path).
1500fn render<T: Template>(tmpl: &T) -> Response {
1501    match tmpl.render() {
1502        Ok(body) => Html(body).into_response(),
1503        Err(err) => {
1504            warn!(%err, "template render failed");
1505            (StatusCode::INTERNAL_SERVER_ERROR, "template render error").into_response()
1506        }
1507    }
1508}
1509
1510/// A minimal web error type so handlers can `?`-propagate `anyhow` failures and
1511/// still return an `impl IntoResponse`. Renders as a `500` with a short message
1512/// by default; a handler may override the status (e.g. `413` for an over-cap
1513/// upload) via [`WebError::with_status`].
1514struct WebError {
1515    err: anyhow::Error,
1516    status: StatusCode,
1517}
1518
1519impl<E: Into<anyhow::Error>> From<E> for WebError {
1520    fn from(err: E) -> Self {
1521        WebError {
1522            err: err.into(),
1523            status: StatusCode::INTERNAL_SERVER_ERROR,
1524        }
1525    }
1526}
1527
1528impl WebError {
1529    /// Attach an explicit HTTP status to render instead of the default `500`.
1530    fn with_status(err: impl Into<anyhow::Error>, status: StatusCode) -> Self {
1531        WebError {
1532            err: err.into(),
1533            status,
1534        }
1535    }
1536}
1537
1538impl IntoResponse for WebError {
1539    fn into_response(self) -> Response {
1540        warn!(error = %self.err, status = %self.status, "request failed");
1541        let body = if self.status == StatusCode::INTERNAL_SERVER_ERROR {
1542            "internal error"
1543        } else {
1544            self.status.canonical_reason().unwrap_or("error")
1545        };
1546        (self.status, body).into_response()
1547    }
1548}
1549
1550/// Map an axum [`MultipartError`] to a [`WebError`] that preserves the error's
1551/// own HTTP status. When a request exceeds the route's `DefaultBodyLimit` the
1552/// multipart extractor reports `413 Payload Too Large`; a malformed body reports
1553/// `400`. Either way this avoids collapsing the failure into a generic `500`.
1554fn multipart_response(err: axum::extract::multipart::MultipartError) -> WebError {
1555    let status = err.status();
1556    WebError::with_status(err, status)
1557}
1558
1559/// A short, human display of a feed/site title for the sidebar/list, falling
1560/// back to the host of a URL and finally to the raw string.
1561fn display_title(title: Option<&str>, url: &str) -> String {
1562    if let Some(t) = title {
1563        let t = t.trim();
1564        if !t.is_empty() {
1565            return t.to_string();
1566        }
1567    }
1568    url::Url::parse(url)
1569        .ok()
1570        .and_then(|u| u.host_str().map(str::to_string))
1571        .unwrap_or_else(|| url.to_string())
1572}
1573
1574/// A display `@handle` for the identity chip: the stored handle if present,
1575/// else the tail of the DID so the chip is never empty.
1576fn display_handle(handle: Option<&str>, did: &str) -> String {
1577    match handle {
1578        Some(h) if !h.trim().is_empty() => format!("@{}", h.trim().trim_start_matches('@')),
1579        _ => did.rsplit(':').next().unwrap_or(did).to_string(),
1580    }
1581}
1582
1583/// Two-letter, lowercase avatar initials from a handle/DID.
1584fn avatar_initials(handle: Option<&str>, did: &str) -> String {
1585    let source = handle
1586        .map(|h| h.trim().trim_start_matches('@'))
1587        .filter(|h| !h.is_empty())
1588        .unwrap_or_else(|| did.rsplit(':').next().unwrap_or(did));
1589    let letters: String = source
1590        .chars()
1591        .filter(|c| c.is_alphanumeric())
1592        .take(2)
1593        .collect::<String>()
1594        .to_lowercase();
1595    if letters.is_empty() {
1596        "fr".to_string()
1597    } else {
1598        letters
1599    }
1600}
1601
1602/// Trim a stored RFC3339 timestamp down to the `YYYY-MM-DD` date for calm,
1603/// low-noise display. Falls back to the raw string if it doesn't look like one.
1604fn display_date(published: Option<&str>) -> String {
1605    // CHARACTERS, not bytes. `p[..10]` panics when byte 10 lands inside a
1606    // multi-byte character, and every caller used to pass a timestamp the feed
1607    // parser had produced. The saved-record path passes `createdAt` straight off
1608    // a PDS record, which the lexicon types as a bare string with no validation
1609    // — written by whatever atproto client the reader used. A `createdAt` of
1610    // "日本語日本語日本" took down the whole starred view, and there is no
1611    // catch-panic layer in the stack, so the page stayed down until the record
1612    // was removed from the very view that would not render.
1613    match published {
1614        Some(p) => p.chars().take(10).collect(),
1615        None => String::new(),
1616    }
1617}
1618
1619/// Percent-encode a value for use in a query string (RFC 3986 unreserved kept).
1620/// Small and dependency-free — the `url` crate's form-encoding isn't exposed for
1621/// a bare value, and this keeps the scope-preserving links honest.
1622fn qenc(s: &str) -> String {
1623    let mut out = String::with_capacity(s.len() * 3);
1624    for b in s.bytes() {
1625        match b {
1626            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
1627                out.push(b as char)
1628            }
1629            _ => out.push_str(&format!("%{b:02X}")),
1630        }
1631    }
1632    out
1633}
1634
1635// ---------------------------------------------------------------------------
1636// Reader: index
1637// ---------------------------------------------------------------------------
1638
1639/// Query for `GET /` — the scope + view selector.
1640#[derive(Debug, Deserialize, Default)]
1641struct IndexQuery {
1642    /// Filter to a single feed by its canonical URL.
1643    #[serde(default)]
1644    feed: Option<String>,
1645    /// Filter to a folder by its `at://` URI (shows every feed in the folder).
1646    #[serde(default)]
1647    folder: Option<String>,
1648    /// `unread` (default) | `all` | `starred`.
1649    #[serde(default)]
1650    view: Option<String>,
1651    /// 1-based page within the selected scope + view. Absent/0 means page 1.
1652    #[serde(default)]
1653    page: Option<u32>,
1654    /// Optional flash message (e.g. after an action redirect).
1655    #[serde(default)]
1656    flash: Option<String>,
1657}
1658
1659/// Rows per page in the reader's list views.
1660///
1661/// The list projection no longer carries article bodies ([`store::EntryListRow`]),
1662/// so a page is on the order of tens of kilobytes rather than the tens or
1663/// hundreds of megabytes an unbounded list of full entries could reach. The page
1664/// bound is the second half of that fix: without it, a reader with a long
1665/// backlog still decides how much memory a single request allocates.
1666const ENTRIES_PER_PAGE: i64 = 100;
1667
1668/// How many pages `total` entries occupy. An empty list is page 1 of 1, so the
1669/// pager reads "1 / 1" rather than "1 / 0".
1670fn page_count_for(total: i64) -> i64 {
1671    ((total + ENTRIES_PER_PAGE - 1) / ENTRIES_PER_PAGE).max(1)
1672}
1673
1674/// Ceiling on the reader's prev/next id list.
1675///
1676/// Unlike the page above, this genuinely spans the whole list — prev/next is the
1677/// reader's position within it — so it is bounded by count rather than paged. At
1678/// 8 bytes per id this is ~40 KB at the cap. Past it the neighbour links stop
1679/// resolving; the article itself still opens, and the list view still pages.
1680const PREV_NEXT_MAX: i64 = 5_000;
1681
1682/// Ceiling on the cached-starred identity set matched against PDS saved records.
1683///
1684/// Deliberately generous: under-reading this set makes a cached article look
1685/// uncached, and an uncached starred row's button deletes the PDS RECORD rather
1686/// than un-starring the entry. Truncating here would change what a click
1687/// destroys, so the cap exists only as a backstop against an absurd starred
1688/// count, not as a routine bound.
1689const STARRED_IDENTITY_MAX: i64 = 20_000;
1690
1691/// Most uncached PDS saved records this handler will hold in memory for one
1692/// request.
1693///
1694/// **A memory bound, not a visibility bound.** These rows are PAGED alongside
1695/// the cached entries, so `ENTRIES_PER_PAGE` decides how many are rendered and
1696/// this only caps how many are collected before slicing. An earlier version used
1697/// it to cap what was SHOWN, which left everything past it invisible and —
1698/// because the un-save control lives on the row, and nothing else in the app
1699/// lists these — unremovable.
1700///
1701/// Well above the PDS list ceiling's practical reach for one reader, so a reader
1702/// meeting it has thousands of saved records and gets a logged, ordered prefix
1703/// rather than a failure.
1704const MAX_UNCACHED_SAVED_ROWS: usize = 5_000;
1705
1706/// A subscription resolved against the local cache: the PDS record + its
1707/// (possibly-missing) cached feed row.
1708struct ResolvedSub {
1709    rkey: String,
1710    sub: Subscription,
1711    feed: Option<store::Feed>,
1712}
1713
1714/// Pull the user's subscriptions (source of truth = PDS), ensure each has a
1715/// local cache row so unread counts work, and return them resolved. Best-effort
1716/// on the sidecar: a failure falls back to the local cache alone.
1717async fn resolve_subscriptions(state: &AppState, did: &str) -> Vec<ResolvedSub> {
1718    let pool = &state.db;
1719    let subs = match state.repo().list_subscriptions_sorted(did).await {
1720        Ok(s) => s,
1721        Err(err) => {
1722            warn!(%err, %did, "could not list PDS subscriptions; showing this DID's cached subscriptions only");
1723            // Fail CLOSED: the PDS is the source of truth for what this DID
1724            // follows. When it is unreachable we must NOT widen the caller's
1725            // authorization surface. Serve from the DID's OWN last-known
1726            // `sub_ref` projection (its own feeds, possibly stale) and leave
1727            // `sub_ref` untouched — never synthesize from every cached feed,
1728            // which would grant cross-tenant read+mutate during any outage.
1729            // A DB failure here is NOT the same as "this DID follows nothing",
1730            // but `unwrap_or_default` rendered it as exactly that: an empty
1731            // sidebar and an empty reader, which arrives as "all my feeds
1732            // vanished". It still degrades to empty — there is nothing better to
1733            // show — but it says so, so the support ticket and the log line can
1734            // be matched up.
1735            let feeds = store::feeds_for_did(pool, did).await.unwrap_or_else(|err| {
1736                warn!(%err, %did, "the PDS is unreachable AND the local subscription \
1737                                   projection could not be read; rendering an EMPTY \
1738                                   feed list, which is not the same as having none");
1739                Vec::new()
1740            });
1741            return feeds
1742                .into_iter()
1743                .map(|f| ResolvedSub {
1744                    rkey: String::new(),
1745                    sub: Subscription::new(f.url.clone(), now_rfc3339()),
1746                    feed: Some(f),
1747                })
1748                .collect();
1749        }
1750    };
1751
1752    // **Deliberately NOT truncated to `max_subs_per_did`.**
1753    //
1754    // The PDS list is unbounded in practice — any client can write subscription
1755    // records, and only the 20,000-record list ceiling stops it — and the first
1756    // attempt at bounding it truncated the list right here. That was the wrong
1757    // place: `sync_sub_refs` below writes `sub_ref` from exactly this set, and
1758    // `sub_ref` is THE per-DID authorization hook, so dropping entries silently
1759    // removed the reader's ability to read OR mutate those feeds. A query-shape
1760    // problem would have become an access problem.
1761    //
1762    // The shape problem was the scope filter emitting one SQL placeholder per
1763    // feed; `store::list_query_sql` now passes the whole set as a single
1764    // `json_each` bind, so there is no size to defend against here and nothing
1765    // to truncate. `max_subs_per_did` stays what it is — a policy cap on ADDING
1766    // feeds — rather than becoming a silent read-time filter.
1767    let mut out = Vec::with_capacity(subs.len());
1768    for (rkey, sub) in subs {
1769        let feed = match store::get_feed_by_url(pool, &sub.url).await {
1770            Ok(Some(f)) => Some(f),
1771            Ok(None) => {
1772                // `sub.url` came out of an atproto record. The lexicon is open —
1773                // ANY client can write a subscription into a user's repo — so
1774                // this is untrusted input on the hot path of `GET /`, and it was
1775                // being stored with none of the three checks the add and import
1776                // paths apply. Two of those are capacity ceilings; this one is
1777                // the invariant in `FeedPrivacy`'s doc comment, which promises a
1778                // private feed URL is "never stored". Writing a
1779                // `…/feed/private/<token>` into the SHARED `feeds` table breaks
1780                // that promise even though `net::guarded_get` still refuses to
1781                // fetch it.
1782                if !feed::is_storable_feed_url(&sub.url, state.config.standard_site)
1783                    || feed::classify_feed_privacy(&sub.url).is_private()
1784                {
1785                    warn!(
1786                        %did,
1787                        "skipping cache row for a subscription URL that is private or not http(s)"
1788                    );
1789                    out.push(ResolvedSub {
1790                        rkey,
1791                        sub,
1792                        feed: None,
1793                    });
1794                    continue;
1795                }
1796                // Upsert a cache row so the sidebar reflects the real follow-list.
1797                //
1798                // A silent failure here is a support ticket with no evidence: no
1799                // `feeds` row means the poller never selects this subscription,
1800                // so the reader sees "I added a feed and it never updates" while
1801                // the PDS record looks perfect. Logged with the URL so the
1802                // failing subscription is identifiable.
1803                if let Err(err) = store::upsert_feed(
1804                    pool,
1805                    &store::NewFeed {
1806                        url: sub.url.clone(),
1807                        title: sub.title.clone(),
1808                        site_url: sub.site_url.clone(),
1809                        ..Default::default()
1810                    },
1811                )
1812                .await
1813                {
1814                    warn!(%err, url = %sub.url, %did, "could not cache a subscribed feed; \
1815                                                       it will not be polled");
1816                }
1817                store::get_feed_by_url(pool, &sub.url).await.ok().flatten()
1818            }
1819            Err(err) => {
1820                warn!(%err, url = %sub.url, "get_feed_by_url failed");
1821                None
1822            }
1823        };
1824        out.push(ResolvedSub { rkey, sub, feed });
1825    }
1826    // Mirror the caller's resolved subscription set into `sub_ref`, so every
1827    // scoped entry/feed read + read/star mutation authorizes against exactly
1828    // the feeds this DID follows right now. This is THE per-DID isolation hook.
1829    sync_sub_refs(pool, did, &out).await;
1830    out
1831}
1832
1833/// Refresh the `sub_ref` projection for `did` to exactly the feed ids present
1834/// in `subs`. Best-effort: a failure here only degrades the scoped reads (they
1835/// fail closed / show fewer rows), never leaks another user's entries.
1836async fn sync_sub_refs(pool: &store::Pool, did: &str, subs: &[ResolvedSub]) {
1837    let feed_ids: Vec<i64> = subs
1838        .iter()
1839        .filter_map(|s| s.feed.as_ref().map(|f| f.id))
1840        .collect();
1841    if let Err(err) = store::replace_sub_refs(pool, did, &feed_ids).await {
1842        warn!(%err, %did, "failed to sync sub_ref projection");
1843    }
1844}
1845
1846/// `GET /` — the reader. Renders the sidebar (folders + feeds from the PDS
1847/// records layer) and the article list for the selected scope + view.
1848async fn index(
1849    State(state): State<AppState>,
1850    headers: HeaderMap,
1851    Query(q): Query<IndexQuery>,
1852) -> Result<Response, WebError> {
1853    let user = match current_session(&state, &headers).await {
1854        Some(u) => u,
1855        // Signed out: serve the public landing page rather than bouncing to
1856        // /login. /login remains the entry point for the actual OAuth sign-in.
1857        None => {
1858            return Ok(render(&LandingTemplate {
1859                version: VERSION,
1860                repo_url: REPO_URL,
1861                crates_url: CRATES_URL,
1862                kofi_url: KOFI_URL,
1863            }))
1864        }
1865    };
1866    let did = user.did.clone();
1867    let pool = &state.db;
1868
1869    let subs = resolve_subscriptions(&state, &did).await;
1870
1871    // View: unread (default) | all | starred.
1872    let view = match q.view.as_deref() {
1873        Some("all") => "all",
1874        Some("starred") => "starred",
1875        _ => "unread",
1876    }
1877    .to_string();
1878    let list_view = list_view_of(q.view.as_deref());
1879
1880    // Which feed URLs are in scope?
1881    let scope_urls = scope_urls_for(&subs, q.feed.as_deref(), q.folder.as_deref());
1882    // …and the feed ids they resolve to. Scope is applied inside the query now,
1883    // so a page is a page of rows the reader will actually see. Filtering after
1884    // a `LIMIT` would have made pages arbitrarily short — sometimes empty — for
1885    // any scope narrower than the whole subscription list.
1886    let scope_ids = scoped_feed_ids(&subs, &scope_urls);
1887
1888    let feed_title_by_id = |id: i64| -> String {
1889        subs.iter()
1890            .find(|s| s.feed.as_ref().map(|f| f.id) == Some(id))
1891            .map(|s| {
1892                display_title(
1893                    s.sub
1894                        .title
1895                        .as_deref()
1896                        .or(s.feed.as_ref().and_then(|f| f.title.as_deref())),
1897                    &s.sub.url,
1898                )
1899            })
1900            .unwrap_or_default()
1901    };
1902
1903    // **One page of the chosen view, filtered, ordered and bounded in SQL.**
1904    //
1905    // All three views used to materialize every matching entry — `SELECT e.*`,
1906    // no `LIMIT`, article bodies included — and the "all" view additionally ran
1907    // one such query PER SUBSCRIBED FEED and merged the results in memory. None
1908    // of the row fields below read the body. See `store::EntryListRow`.
1909    // **Saved records the cache cannot show.**
1910    //
1911    // The starred view is built from local `entries`, so a saved record whose
1912    // article was never cached here is invisible — the case that matters is
1913    // starring in ANOTHER atproto reader, which is the portability the shared
1914    // lexicon exists for. Those rows are rendered from the PDS record alone.
1915    let mut uncached: Vec<EntryRow> = Vec::new();
1916    if view == "starred" {
1917        // **Match against every SUBSCRIBED cached starred entry, not `source`.**
1918        //
1919        // `source` has already been filtered by feed/folder. Matching against it
1920        // meant an entry that IS cached but sits outside the current filter
1921        // looked uncached — so it rendered as a "not cached" row whose star
1922        // button deletes the PDS RECORD instead of un-starring the entry. A
1923        // scope filter must not change what is destroyed. Paging is the same
1924        // hazard in a new form: matching against the visible PAGE would make
1925        // every cached article outside it look uncached. Hence a dedicated
1926        // identity query over the whole starred set — urls and guids only, no
1927        // bodies — rather than reusing `source`.
1928        //
1929        // One gap remains BY DESIGN, and is handled at the other end. This query
1930        // still carries the `sub_ref` predicate, so a starred, cached entry in a
1931        // feed the reader has UNSUBSCRIBED from is absent here and its record
1932        // renders as uncached. That is the right rendering — the article is no
1933        // longer part of any feed the reader follows, and the PDS record is what
1934        // still holds it — but it means the un-save button is the record-deleting
1935        // one. `unsave_record` therefore clears the local star too, so the two
1936        // stores agree however the row got classified. Dropping the predicate
1937        // here instead would have made the row link to `/entries/{id}`, which is
1938        // `sub_ref`-scoped and would 404.
1939        //
1940        // **Three ways this can be unusable, and all three fail CLOSED.** With an
1941        // incomplete identity set, a cached article looks uncached and renders an
1942        // un-save button that deletes the PDS RECORD. Showing no uncached rows
1943        // loses rows for one render; getting this wrong loses data permanently,
1944        // so every uncertain case suppresses them.
1945        let identities = match store::starred_identities(pool, &did, STARRED_IDENTITY_MAX).await {
1946            Ok(store::StarredIdentities::All(rows)) => Some(rows),
1947            // The cap is a memory backstop, and reaching it means the set is an
1948            // arbitrary subset. It used to return that subset with no way to
1949            // tell, so every starred article outside it got the destructive
1950            // button.
1951            Ok(store::StarredIdentities::Truncated) => {
1952                warn!(
1953                    %did,
1954                    cap = STARRED_IDENTITY_MAX,
1955                    "cached-starred set exceeded its cap; suppressing uncached saved rows \
1956                     rather than rendering record-deleting buttons for cached articles"
1957                );
1958                None
1959            }
1960            Err(err) => {
1961                warn!(%err, %did, "cached-starred identity lookup failed; \
1962                                    suppressing uncached saved rows this render");
1963                None
1964            }
1965        };
1966        // The escape hatch asks whether this DID has ANY cached starred entry —
1967        // not whether the current SCOPE does. `total` is narrowed by
1968        // `?feed=`/`?folder=` while the identity set spans every feed, so
1969        // comparing them waved the fail-closed condition through for any narrow
1970        // scope: a record whose `feedUrl` matched the filter while its cached
1971        // entry lived under another feed rendered as uncached.
1972        let identities_ok = identities.is_some();
1973        let identities = identities.unwrap_or_default();
1974        let cached_urls: std::collections::HashSet<&str> = identities
1975            .iter()
1976            .filter_map(|(url, _)| url.as_deref())
1977            .collect();
1978        let cached_guids: std::collections::HashSet<&str> =
1979            identities.iter().map(|(_, guid)| guid.as_str()).collect();
1980
1981        // Collected in full here, sliced per page later. They sort after every
1982        // cached row, so the two lists form one sequence that the pager walks —
1983        // see the slice below. Collected BEFORE the page is chosen because the
1984        // page count depends on how many there are.
1985        // Bounded like everything else on this page. These come from the PDS
1986        // (up to the list ceiling — 20,000 on the sidecar backend, 5,000 on
1987        // `backend=rust`, whose caps are a quarter of the other's) and are
1988        // appended whole to the last page, so `ENTRIES_PER_PAGE` does not
1989        // constrain them at all. The
1990        // cap is generous — a reader with more saved-elsewhere records than this
1991        // is not the case being designed for — but a response has to have a size
1992        // an operator can reason about.
1993        let mut uncached_dropped = 0usize;
1994        match state.repo().list_saved_sorted(&did).await {
1995            Ok(saved) if identities_ok => {
1996                for (rkey, item) in saved {
1997                    let known = cached_urls.contains(item.url.as_str())
1998                        || item
1999                            .entry_id
2000                            .as_deref()
2001                            .is_some_and(|g| cached_guids.contains(g));
2002                    if known {
2003                        continue;
2004                    }
2005                    // And the scope filter applies to these rows too. Without
2006                    // it, `?feed=X` still listed saved records from every other
2007                    // feed — the filter silently did nothing for them.
2008                    if let Some(urls) = &scope_urls {
2009                        match item.feed_url.as_deref() {
2010                            Some(feed_url) if urls.iter().any(|u| u == feed_url) => {}
2011                            // A saved record with no `feedUrl` cannot be placed
2012                            // in any feed's scope, so it belongs only to the
2013                            // unfiltered view.
2014                            _ => continue,
2015                        }
2016                    }
2017                    // **`safe_link` FIRST, and a failure no longer drops the row.**
2018                    //
2019                    // `item.url` is attacker-controlled — a saved record written
2020                    // by any client — and it lands in an `href`. Askama escapes
2021                    // HTML metacharacters but not SCHEMES, so `javascript:`
2022                    // survives escaping intact. This project already built the
2023                    // helper for exactly that, and `feed.rs` uses it on the
2024                    // equivalent link; this path was simply not routed through it.
2025                    //
2026                    // The real defect was what a failure DID: it `continue`d, so
2027                    // the row vanished entirely — no badge, no count, nothing —
2028                    // and the only trace was a `debug!` below any realistic
2029                    // filter. That makes the record unremovable FROM HERE, because
2030                    // the un-save button lives on the row; the reader has to open
2031                    // a different atproto client to get rid of it. A bad URL is a
2032                    // reason to withhold the LINK, not the row.
2033                    //
2034                    // The check also moved ABOVE the poll nudge. That is ordering
2035                    // hygiene rather than a fix: the nudge keys on `feed_url`, not
2036                    // on the URL being rejected here, and is already gated on the
2037                    // reader actually subscribing to that feed — so it was never
2038                    // reachable by an unusable `item.url`. Deciding whether a
2039                    // record is renderable before doing anything outbound on its
2040                    // behalf is simply the order that stays correct if either of
2041                    // those two facts later stops being true.
2042                    let link = SafeLink::external(&item.url);
2043                    if link.is_empty() {
2044                        warn!(
2045                            %did, %rkey,
2046                            "a saved record has an unusable URL; rendering it without a link \
2047                             so it can still be removed"
2048                        );
2049                    }
2050
2051                    // Opportunistic re-fetch: if the reader still subscribes to
2052                    // the feed, make it due now. If the article is still inside
2053                    // the feed's window the poller caches it normally and this
2054                    // row becomes a real entry on its own — no synthetic rows in
2055                    // the shared cache, which every subscriber would otherwise
2056                    // see as a content-less entry.
2057                    // **Bound the WORK, not just the response.** This check sat
2058                    // after the nudge and the `subs` scan below, so every render
2059                    // still walked all ≤20,000 PDS records, ran a subs-length
2060                    // string scan per record, and issued up to that many
2061                    // `mark_feed_due` round-trips on a 5-connection pool — then
2062                    // discarded everything past the cap. A cap that runs after
2063                    // the expensive part is a cap on the output only.
2064                    if uncached.len() >= MAX_UNCACHED_SAVED_ROWS {
2065                        uncached_dropped += 1;
2066                        continue;
2067                    }
2068                    if let Some(feed_url) = item.feed_url.as_deref() {
2069                        if subs.iter().any(|s| s.sub.url == feed_url) {
2070                            // Bounded to one nudge per feed per poll interval —
2071                            // see `mark_feed_due`. Unbounded, a reload loop here
2072                            // becomes outbound amplification.
2073                            let stale_before = (chrono::Utc::now()
2074                                - chrono::Duration::from_std(state.config.poll_interval)
2075                                    .unwrap_or_else(|_| chrono::Duration::hours(1)))
2076                            .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
2077                            if let Err(err) =
2078                                store::mark_feed_due(pool, feed_url, &stale_before).await
2079                            {
2080                                tracing::debug!(%err, %feed_url, "could not nudge a feed for a saved article");
2081                            }
2082                        }
2083                    }
2084                    uncached.push(EntryRow {
2085                        id: 0,
2086                        title: item
2087                            .title
2088                            .clone()
2089                            .filter(|t| !t.trim().is_empty())
2090                            // Falling back to the URL is fine for a link we are
2091                            // willing to render, and wrong for one we are not:
2092                            // it would put the exact string `safe_link` just
2093                            // rejected into the page as the record's name. The
2094                            // rkey is what the un-save button acts on, so it is
2095                            // the honest identifier for a row that has nothing
2096                            // else trustworthy to show.
2097                            .unwrap_or_else(|| {
2098                                if link.is_empty() {
2099                                    format!("Saved item {rkey}")
2100                                } else {
2101                                    item.url.clone()
2102                                }
2103                            }),
2104                        feed_title: item.feed_url.clone().unwrap_or_default(),
2105                        published: display_date(Some(&item.created_at)),
2106                        read: false,
2107                        starred: true,
2108                        // Empty = "render this row without an anchor". The
2109                        // template branches on it, so the rejected URL never
2110                        // reaches an `href` even as an escaped string.
2111                        link,
2112                        cached: false,
2113                        rkey,
2114                    });
2115                }
2116            }
2117            // Identity lookup was unusable — see the fail-closed note above.
2118            Ok(_) => {}
2119            Err(err) => warn!(%err, %did, "could not list saved records from the PDS"),
2120        }
2121        if uncached_dropped > 0 {
2122            warn!(
2123                %did,
2124                dropped = uncached_dropped,
2125                cap = MAX_UNCACHED_SAVED_ROWS,
2126                "more saved records than this instance will hold in one response; the \
2127                 rest are not reachable from here"
2128            );
2129        }
2130    }
2131
2132    // **One sequence, two sources.** The cached rows come from SQL, the uncached
2133    // PDS records follow them, and the pager walks the concatenation.
2134    //
2135    // The first version appended the uncached rows to the last page only and
2136    // kept them out of `total`, which left everything past a cap invisible AND
2137    // unremovable — the un-save button lives on the row, and there is no other
2138    // surface in the app that lists these. That is the same "unremovable FROM
2139    // HERE" hazard the `safe_link` fix above exists to prevent, reintroduced
2140    // forty lines later by a bound meant to protect memory.
2141    //
2142    // Paging the concatenation makes every record reachable and needs no cap on
2143    // what is RENDERED — one page is one page either way. The version before
2144    // that inflated `total` while clamping on the cached count, which advertised
2145    // a page the clamp could never reach; both numbers come from the same total
2146    // now, which is what makes that impossible rather than merely fixed.
2147    let total_cached =
2148        store::count_entries_for_view(pool, &did, list_view, scope_ids.as_deref()).await?;
2149    let uncached_len = uncached.len();
2150    let total = total_cached + uncached_len as i64;
2151    // Clamped to the range that exists. Past the end the list is empty, and the
2152    // empty state renders instead of the pager — which would strand a reader who
2153    // typed a page number, or who paged to the end and then marked entries read
2154    // out from under their own URL. Showing the last page is the answer to both.
2155    let page = i64::from(q.page.unwrap_or(1).max(1)).min(page_count_for(total));
2156    let offset = (page - 1) * ENTRIES_PER_PAGE;
2157    // Past the cached rows this returns nothing, which is exactly right: the
2158    // page is then made up entirely of uncached ones.
2159    let source = store::list_entries(
2160        pool,
2161        &did,
2162        list_view,
2163        scope_ids.as_deref(),
2164        ENTRIES_PER_PAGE,
2165        offset,
2166    )
2167    .await?;
2168    // **Both halves of the page are computed from the COUNT alone.**
2169    //
2170    // `total_cached` (a COUNT) and `source` (a SELECT) are separate unsynchronised
2171    // queries, so they can disagree about how many cached rows exist. Any part of
2172    // the page composition that reads `source.len()` inherits that disagreement.
2173    //
2174    // `cached_allotment` is this page's cached share according to the snapshot,
2175    // and it is what the uncached `skip`/`take` are derived from — so consecutive
2176    // pages tile the uncached list exactly, whichever way the count drifted.
2177    // `source` is then truncated to it only to avoid rendering rows the next page
2178    // will also claim.
2179    //
2180    // The previous version took `skip` from the count but `take` from
2181    // `source.len()`, which agreed only when the count UNDERSTATED. Overstating —
2182    // an un-star or a retention delete landing between the two queries — made
2183    // page N render `uncached[0..70]` while page N+1 rendered `uncached[50..80]`,
2184    // putting twenty rows, each carrying the record-DELETING un-save button, on
2185    // two pages at once. The comment claimed that shape was impossible; it was
2186    // merely rarer.
2187    let cached_allotment = (total_cached - offset).clamp(0, ENTRIES_PER_PAGE) as usize;
2188    let cached_here = cached_allotment.min(source.len());
2189    // Only compose when there is something to compose WITH. `uncached` is empty
2190    // on every view but `starred`, and truncating there just drops trailing rows
2191    // that no page then shows — the poller inserting between the COUNT and the
2192    // SELECT was enough to trigger it.
2193    let source = if uncached_len == 0 {
2194        &source[..]
2195    } else {
2196        &source[..cached_here]
2197    };
2198    let uncached_page: Vec<EntryRow> = {
2199        let skip = (offset - total_cached).max(0) as usize;
2200        let take = (ENTRIES_PER_PAGE as usize) - cached_allotment;
2201        uncached.into_iter().skip(skip).take(take).collect()
2202    };
2203    // This page's slice, used only to append below. The heading needs the
2204    // WHOLE-list figure, which is the set's size before slicing.
2205    let uncached_total = uncached_len as i64;
2206
2207    // The scope/view suffix carried onto every entry link (built once).
2208    let entry_scope_qs = {
2209        let mut parts = Vec::new();
2210        if let Some(f) = q.feed.as_deref() {
2211            parts.push(format!("feed={}", qenc(f)));
2212        }
2213        if let Some(f) = q.folder.as_deref() {
2214            parts.push(format!("folder={}", qenc(f)));
2215        }
2216        if view != "unread" {
2217            parts.push(format!("view={}", qenc(&view)));
2218        }
2219        parts.join("&")
2220    };
2221    let entries: Vec<EntryRow> = source
2222        .iter()
2223        .map(|e| EntryRow {
2224            id: e.id,
2225            title: e
2226                .title
2227                .clone()
2228                .filter(|t| !t.trim().is_empty())
2229                .unwrap_or_else(|| "(untitled)".to_string()),
2230            feed_title: feed_title_by_id(e.feed_id),
2231            published: display_date(e.published.as_deref()),
2232            // Both bits ride along on the row's own `entry_state` join now. They
2233            // used to be membership tests against the full unread and starred
2234            // sets, which is why those two lists were fetched in their entirety
2235            // on every render even when the page showed a hundred rows.
2236            read: e.read,
2237            starred: e.starred,
2238            link: SafeLink::entry(e.id, &entry_scope_qs),
2239            cached: true,
2240            rkey: String::new(),
2241        })
2242        .collect();
2243
2244    // The uncached slice for this page follows the cached rows.
2245    let mut entries = entries;
2246    entries.extend(uncached_page);
2247    let entries = entries;
2248
2249    let selected_feed = q.feed.as_deref();
2250    let selected_folder = q.folder.as_deref();
2251
2252    // Build the shared sidebar (folders + loose feeds, with unread counts).
2253    let (folder_views, loose_feeds, _folder_options) =
2254        build_sidebar(&state, &did, &subs, selected_feed, selected_folder).await;
2255
2256    // Heading + scope query-string suffix.
2257    let (heading, scope_qs) = if let Some(feed_url) = selected_feed {
2258        let name = subs
2259            .iter()
2260            .find(|s| s.sub.url == feed_url)
2261            .map(|s| {
2262                display_title(
2263                    s.sub
2264                        .title
2265                        .as_deref()
2266                        .or(s.feed.as_ref().and_then(|f| f.title.as_deref())),
2267                    &s.sub.url,
2268                )
2269            })
2270            .unwrap_or_else(|| display_title(None, feed_url));
2271        (name, format!("feed={}", qenc(feed_url)))
2272    } else if let Some(folder_uri) = selected_folder {
2273        let name = folder_views
2274            .iter()
2275            .find(|f| f.uri == folder_uri)
2276            .map(|f| f.name.clone())
2277            .unwrap_or_else(|| "Folder".to_string());
2278        (name, format!("folder={}", qenc(folder_uri)))
2279    } else {
2280        let h = match view.as_str() {
2281            "all" => "All",
2282            "starred" => "Starred",
2283            _ => "Unread",
2284        };
2285        (h.to_string(), String::new())
2286    };
2287
2288    let feed_scope = selected_feed.map(str::to_string);
2289    let nav = build_nav(&user, &view, scope_qs, folder_views, loose_feeds, false);
2290
2291    // Pager links. `entry_scope_qs` already carries feed/folder/view, so the
2292    // page number is the only thing appended — which keeps a paged link
2293    // identical to an unpaged one in every other respect.
2294    let page_href = |n: i64| -> String {
2295        let mut parts = Vec::new();
2296        if !entry_scope_qs.is_empty() {
2297            parts.push(entry_scope_qs.clone());
2298        }
2299        if n > 1 {
2300            parts.push(format!("page={n}"));
2301        }
2302        if parts.is_empty() {
2303            "/".to_string()
2304        } else {
2305            format!("/?{}", parts.join("&"))
2306        }
2307    };
2308    let prev_href = (page > 1).then(|| page_href(page - 1));
2309    let next_href = (page * ENTRIES_PER_PAGE < total).then(|| page_href(page + 1));
2310
2311    let tmpl = IndexTemplate {
2312        version: VERSION,
2313        repo_url: REPO_URL,
2314        kofi_url: KOFI_URL,
2315        flash: q.flash.unwrap_or_default(),
2316        nav,
2317        entries,
2318        heading,
2319        feed_scope,
2320        total,
2321        // Whole-list figure, so it sits beside `total` without double counting.
2322        // The per-page slice is composed above and is not a heading number.
2323        uncached_total,
2324        page,
2325        page_count: page_count_for(total),
2326        prev_href,
2327        next_href,
2328    };
2329    Ok(render(&tmpl))
2330}
2331
2332/// Query for `GET /manage` — carries an optional flash after an action redirect.
2333#[derive(Debug, Deserialize, Default)]
2334struct ManageQuery {
2335    #[serde(default)]
2336    flash: Option<String>,
2337}
2338
2339/// `GET /manage` — the feed-management page. Renders the rail plus the subscribe
2340/// / your-feeds / OPML surfaces; the forms POST to the existing routes
2341/// (`/subscriptions`, `/folders`, `/opml`, …). A read/render route only — no
2342/// mutation logic of its own.
2343async fn manage(
2344    State(state): State<AppState>,
2345    headers: HeaderMap,
2346    Query(q): Query<ManageQuery>,
2347) -> Result<Response, WebError> {
2348    let user = match current_session(&state, &headers).await {
2349        Some(u) => u,
2350        None => return Ok(Redirect::to("/login").into_response()),
2351    };
2352    let did = user.did.clone();
2353
2354    let subs = resolve_subscriptions(&state, &did).await;
2355    let (folder_views, loose_feeds, folder_options) =
2356        build_sidebar(&state, &did, &subs, None, None).await;
2357
2358    // Clone the sidebar for the rail; the page body reuses folders/loose feeds.
2359    let nav = build_nav(
2360        &user,
2361        "unread",
2362        String::new(),
2363        folder_views.iter().map(clone_folder_view).collect(),
2364        loose_feeds.iter().map(clone_feed_view).collect(),
2365        true,
2366    );
2367
2368    let tmpl = ManageTemplate {
2369        version: VERSION,
2370        repo_url: REPO_URL,
2371        kofi_url: KOFI_URL,
2372        flash: q.flash.unwrap_or_default(),
2373        nav,
2374        folder_options,
2375        folders: folder_views,
2376        loose_feeds,
2377    };
2378    Ok(render(&tmpl))
2379}
2380
2381/// Shallow clone helpers so `/manage` can hand the same sidebar to both the rail
2382/// (`Nav`) and the page body without an extra DB round-trip.
2383fn clone_feed_view(f: &FeedView) -> FeedView {
2384    FeedView {
2385        rkey: f.rkey.clone(),
2386        url: f.url.clone(),
2387        title: f.title.clone(),
2388        unread: f.unread,
2389        selected: f.selected,
2390        folder: f.folder.clone(),
2391    }
2392}
2393
2394fn clone_folder_view(f: &FolderView) -> FolderView {
2395    FolderView {
2396        rkey: f.rkey.clone(),
2397        uri: f.uri.clone(),
2398        name: f.name.clone(),
2399        feeds: f.feeds.iter().map(clone_feed_view).collect(),
2400        selected: f.selected,
2401    }
2402}
2403
2404/// The set of feed URLs a scope covers: `Some([one url])` for a single-feed
2405/// scope, `Some([urls…])` for a folder (its member feeds), or `None` for the
2406/// unscoped "everything" view. A folder scope takes the feed scope when both are
2407/// somehow present (feed wins, matching the query precedence elsewhere).
2408fn scope_urls_for(
2409    subs: &[ResolvedSub],
2410    feed: Option<&str>,
2411    folder: Option<&str>,
2412) -> Option<Vec<String>> {
2413    if let Some(feed_url) = feed {
2414        Some(vec![feed_url.to_string()])
2415    } else {
2416        folder.map(|folder_uri| {
2417            subs.iter()
2418                .filter(|s| s.sub.folder.as_deref() == Some(folder_uri))
2419                .map(|s| s.sub.url.clone())
2420                .collect()
2421        })
2422    }
2423}
2424
2425/// The `at://` URI for a folder record given the owner DID + rkey.
2426fn folder_uri(did: &str, rkey: &str) -> String {
2427    format!("at://{did}/{}/{rkey}", lexicon::nsid::FOLDER)
2428}
2429
2430/// Build the sidebar folder/loose-feed views (with per-feed unread counts) for a
2431/// DID — the shared source for both the reader index and the rail on every
2432/// chrome page. `selected_feed` / `selected_folder` drive `aria-current`.
2433async fn build_sidebar(
2434    state: &AppState,
2435    did: &str,
2436    subs: &[ResolvedSub],
2437    selected_feed: Option<&str>,
2438    selected_folder: Option<&str>,
2439) -> (Vec<FolderView>, Vec<FeedView>, Vec<FolderOption>) {
2440    let pool = &state.db;
2441    // Counted in SQL. This used to fetch every unread ENTRY — article bodies and
2442    // all — purely to `.filter().count()` them in Rust, on every page that
2443    // renders chrome, which made the sidebar the most frequently executed
2444    // instance of the unbounded-projection problem.
2445    let unread_counts = store::unread_counts_by_feed(pool, did)
2446        .await
2447        .unwrap_or_else(|err| {
2448            warn!(%err, %did, "sidebar unread counts failed; rendering zeroes");
2449            Default::default()
2450        });
2451    let folders = state
2452        .repo()
2453        .list_folders_sorted(did)
2454        .await
2455        .unwrap_or_default();
2456
2457    let unread_count = |feed_id: Option<i64>| -> i64 {
2458        feed_id
2459            .and_then(|id| unread_counts.get(&id).copied())
2460            .unwrap_or(0)
2461    };
2462    let mk_feed_view = |s: &ResolvedSub| FeedView {
2463        rkey: s.rkey.clone(),
2464        url: s.sub.url.clone(),
2465        title: display_title(
2466            s.sub
2467                .title
2468                .as_deref()
2469                .or(s.feed.as_ref().and_then(|f| f.title.as_deref())),
2470            &s.sub.url,
2471        ),
2472        unread: unread_count(s.feed.as_ref().map(|f| f.id)),
2473        selected: selected_feed == Some(s.sub.url.as_str()),
2474        folder: s.sub.folder.clone(),
2475    };
2476
2477    let mut folder_views = Vec::with_capacity(folders.len());
2478    for (rkey, folder) in &folders {
2479        let uri = folder_uri(did, rkey);
2480        let feeds: Vec<FeedView> = subs
2481            .iter()
2482            .filter(|s| s.sub.folder.as_deref() == Some(uri.as_str()))
2483            .map(mk_feed_view)
2484            .collect();
2485        folder_views.push(FolderView {
2486            rkey: rkey.clone(),
2487            uri: uri.clone(),
2488            name: folder.name.clone(),
2489            feeds,
2490            selected: selected_folder == Some(uri.as_str()),
2491        });
2492    }
2493
2494    let known_uris: std::collections::HashSet<String> =
2495        folders.iter().map(|(r, _)| folder_uri(did, r)).collect();
2496    let loose_feeds: Vec<FeedView> = subs
2497        .iter()
2498        .filter(|s| {
2499            s.sub
2500                .folder
2501                .as_deref()
2502                .map(|f| !known_uris.contains(f))
2503                .unwrap_or(true)
2504        })
2505        .map(mk_feed_view)
2506        .collect();
2507
2508    let folder_options: Vec<FolderOption> = folders
2509        .iter()
2510        .map(|(rkey, folder)| FolderOption {
2511            name: folder.name.clone(),
2512            uri: folder_uri(did, rkey),
2513        })
2514        .collect();
2515
2516    (folder_views, loose_feeds, folder_options)
2517}
2518
2519/// Assemble the shared rail [`Nav`] for a chrome page.
2520fn build_nav(
2521    user: &CurrentUser,
2522    view: &str,
2523    scope_qs: String,
2524    folders: Vec<FolderView>,
2525    loose_feeds: Vec<FeedView>,
2526    manage_active: bool,
2527) -> Nav {
2528    Nav {
2529        handle: display_handle(user.handle.as_deref(), &user.did),
2530        avatar: avatar_initials(user.handle.as_deref(), &user.did),
2531        view: view.to_string(),
2532        scope_qs,
2533        folders,
2534        loose_feeds,
2535        manage_active,
2536    }
2537}
2538
2539// ---------------------------------------------------------------------------
2540// Reader: single entry
2541// ---------------------------------------------------------------------------
2542
2543/// Query for `GET /entries/:id` — carries the reading context (scope + view) so
2544/// prev/next and "back" stay within the list the reader came from.
2545#[derive(Debug, Deserialize, Default)]
2546struct EntryQuery {
2547    #[serde(default)]
2548    feed: Option<String>,
2549    #[serde(default)]
2550    folder: Option<String>,
2551    #[serde(default)]
2552    view: Option<String>,
2553}
2554
2555/// `GET /entries/:id` — the clean reader view for one entry, with prev/next
2556/// within the current reading list.
2557async fn entry_view(
2558    State(state): State<AppState>,
2559    headers: HeaderMap,
2560    Path(id): Path<i64>,
2561    Query(q): Query<EntryQuery>,
2562) -> Result<Response, WebError> {
2563    let user = match current_session(&state, &headers).await {
2564        Some(u) => u,
2565        None => return Ok(Redirect::to("/login").into_response()),
2566    };
2567    let did = user.did.clone();
2568    let pool = &state.db;
2569
2570    // Resolve subscriptions FIRST: this refreshes the `sub_ref` projection so
2571    // the per-DID entry gate below authorizes against the caller's current PDS
2572    // subscription set (not another user's cached feeds).
2573    let subs = resolve_subscriptions(&state, &did).await;
2574
2575    let entry = match get_entry_by_id(pool, &did, id).await? {
2576        Some(e) => e,
2577        None => return Ok((StatusCode::NOT_FOUND, "entry not found").into_response()),
2578    };
2579
2580    let feed_title = feed_title_by_entry(pool, entry.feed_id).await;
2581
2582    let read = entry_is_read(pool, &did, id).await?;
2583    let starred = entry_is_starred(pool, &did, id).await?;
2584
2585    // Reconstruct the current list to compute prev/next, so paging in the reader
2586    // matches what the list showed.
2587    let (prev_id, next_id) = neighbors_in_scope(&state, &did, &q, id).await;
2588
2589    let back_qs = scope_query(&q);
2590
2591    let (folder_views, loose_feeds, _) =
2592        build_sidebar(&state, &did, &subs, q.feed.as_deref(), q.folder.as_deref()).await;
2593    let nav_view = match q.view.as_deref() {
2594        Some("all") => "all",
2595        Some("starred") => "starred",
2596        _ => "unread",
2597    };
2598    let nav = build_nav(
2599        &user,
2600        nav_view,
2601        back_qs.clone(),
2602        folder_views,
2603        loose_feeds,
2604        false,
2605    );
2606
2607    let tmpl = EntryTemplate {
2608        version: VERSION,
2609        repo_url: REPO_URL,
2610        kofi_url: KOFI_URL,
2611        nav,
2612        id: entry.id,
2613        title: entry
2614            .title
2615            .clone()
2616            .filter(|t| !t.trim().is_empty())
2617            .unwrap_or_else(|| "(untitled)".to_string()),
2618        feed_title,
2619        author: entry.author.clone().filter(|a| !a.trim().is_empty()),
2620        published: display_date(entry.published.as_deref()),
2621        url: entry.url.as_deref().and_then(SafeLink::external_opt),
2622        content_html: entry.content_html.clone(),
2623        read,
2624        starred,
2625        back_qs,
2626        prev_id,
2627        next_id,
2628        oob: false,
2629    };
2630    Ok(render(&tmpl))
2631}
2632
2633/// Compute the prev/next entry ids around `current` within the reader's current
2634/// scope + view, so the reader view can offer keyboard/paging navigation.
2635async fn neighbors_in_scope(
2636    state: &AppState,
2637    did: &str,
2638    q: &EntryQuery,
2639    current: i64,
2640) -> (Option<i64>, Option<i64>) {
2641    let idx_q = IndexQuery {
2642        feed: q.feed.clone(),
2643        folder: q.folder.clone(),
2644        view: q.view.clone(),
2645        // Neighbours span the whole list, not the page the reader arrived from.
2646        page: None,
2647        flash: None,
2648    };
2649    let ids = list_entry_ids(state, did, &idx_q).await;
2650    let pos = ids.iter().position(|&x| x == current);
2651    match pos {
2652        Some(p) => {
2653            let prev = if p > 0 { Some(ids[p - 1]) } else { None };
2654            let next = ids.get(p + 1).copied();
2655            (prev, next)
2656        }
2657        None => (None, None),
2658    }
2659}
2660
2661/// The ordered entry ids for a scope + view — the same ordering `index` renders,
2662/// used for reader prev/next. Best-effort; PDS failures degrade to local cache.
2663async fn list_entry_ids(state: &AppState, did: &str, q: &IndexQuery) -> Vec<i64> {
2664    let pool = &state.db;
2665    let subs = resolve_subscriptions(state, did).await;
2666
2667    let scope_urls = scope_urls_for(&subs, q.feed.as_deref(), q.folder.as_deref());
2668
2669    // Ids only, and bounded. This used to fetch whole entries — bodies included
2670    // — for all three views and then throw everything but `id` away; the "all"
2671    // branch additionally ran one unbounded query PER FEED and sorted the union
2672    // in memory. Scope is now a feed-id restriction inside the query, so the
2673    // database does the filtering and the ordering exactly once.
2674    store::list_entry_ids(
2675        pool,
2676        did,
2677        list_view_of(q.view.as_deref()),
2678        scoped_feed_ids(&subs, &scope_urls).as_deref(),
2679        PREV_NEXT_MAX,
2680    )
2681    .await
2682    .unwrap_or_else(|err| {
2683        warn!(%err, %did, "prev/next id list failed; the reader loses its neighbour links");
2684        Vec::new()
2685    })
2686}
2687
2688/// Map the `?view=` query value onto the store's list view. Anything
2689/// unrecognised is the unread default, matching `index`.
2690fn list_view_of(view: Option<&str>) -> store::ListView {
2691    match view {
2692        Some("all") => store::ListView::All,
2693        Some("starred") => store::ListView::Starred,
2694        _ => store::ListView::Unread,
2695    }
2696}
2697
2698/// Translate a feed/folder scope into the feed ids to restrict a list query to.
2699///
2700/// `None` means unscoped (every subscribed feed). `Some(&[])` means the scope
2701/// matched no local feed, which must return nothing rather than everything — so
2702/// the empty vec is deliberately preserved, not collapsed back into `None`.
2703fn scoped_feed_ids(subs: &[ResolvedSub], scope_urls: &Option<Vec<String>>) -> Option<Vec<i64>> {
2704    let urls = scope_urls.as_ref()?;
2705    Some(
2706        subs.iter()
2707            .filter(|s| urls.contains(&s.sub.url))
2708            .filter_map(|s| s.feed.as_ref().map(|f| f.id))
2709            .collect(),
2710    )
2711}
2712
2713/// Build a `?…` query string that preserves the reading scope + view for links.
2714fn scope_query(q: &EntryQuery) -> String {
2715    let mut parts = Vec::new();
2716    if let Some(f) = q.feed.as_deref() {
2717        parts.push(format!("feed={}", qenc(f)));
2718    }
2719    if let Some(f) = q.folder.as_deref() {
2720        parts.push(format!("folder={}", qenc(f)));
2721    }
2722    if let Some(v) = q.view.as_deref() {
2723        if v != "unread" {
2724            parts.push(format!("view={}", qenc(v)));
2725        }
2726    }
2727    parts.join("&")
2728}
2729
2730// ---------------------------------------------------------------------------
2731// Mark read / unread
2732// ---------------------------------------------------------------------------
2733
2734/// Form body for `POST /entries/:id/read`.
2735#[derive(Debug, Deserialize)]
2736struct ReadForm {
2737    #[serde(default)]
2738    read: Option<String>,
2739}
2740
2741/// `POST /entries/:id/read` — toggle an entry's read-state for the current DID.
2742async fn mark_read(
2743    State(state): State<AppState>,
2744    Path(id): Path<i64>,
2745    headers: HeaderMap,
2746    Form(form): Form<ReadForm>,
2747) -> Result<Response, WebError> {
2748    let did = match current_did(&state, &headers).await {
2749        Some(d) => d,
2750        None => return Ok(Redirect::to("/login").into_response()),
2751    };
2752    let pool = &state.db;
2753
2754    let read = matches!(
2755        form.read.as_deref(),
2756        Some("true") | Some("1") | Some("on") | None
2757    );
2758
2759    // Refresh the caller's `sub_ref` projection, then apply the AUTHORIZED
2760    // mutation: `mark_read` only writes when `did` subscribes to the entry's
2761    // feed. A non-subscriber gets a 404, never a mutation of someone else's
2762    // (or the shared cache's) state.
2763    resolve_subscriptions(&state, &did).await;
2764    if !store::mark_read(pool, &did, id, read).await? {
2765        return Ok((StatusCode::NOT_FOUND, "entry not found").into_response());
2766    }
2767
2768    if !is_htmx(&headers) {
2769        return Ok(Redirect::to("/").into_response());
2770    }
2771
2772    // The reader view swaps an out-of-band action-bar fragment (its `<li>` isn't
2773    // in the DOM), so its button's hidden value + aria-pressed update in place
2774    // and a second keypress can reverse the toggle. The list view swaps the row.
2775    if is_reader_request(&headers) {
2776        let starred = entry_is_starred(pool, &did, id).await?;
2777        return Ok(render(&EntryActionBarTemplate {
2778            id,
2779            read,
2780            starred,
2781            oob: true,
2782        }));
2783    }
2784
2785    let row = build_entry_row(pool, &did, id, Some(read)).await?;
2786    match row {
2787        Some(r) => Ok(render(&EntryRowTemplate { e: r })),
2788        None => Ok((StatusCode::NOT_FOUND, "entry not found").into_response()),
2789    }
2790}
2791
2792// ---------------------------------------------------------------------------
2793// Star / save
2794// ---------------------------------------------------------------------------
2795
2796/// Form body for `POST /entries/:id/star`.
2797#[derive(Debug, Deserialize)]
2798struct StarForm {
2799    #[serde(default)]
2800    starred: Option<String>,
2801}
2802
2803/// `POST /entries/:id/star` — star/unstar an entry.
2804///
2805/// Sets the local `starred` bit (fast working copy) and writes/removes a
2806/// `community.lexicon.rss.saved` record in the user's PDS (stars are worth
2807/// owning). The PDS write is best-effort — the local star still lands.
2808async fn toggle_star(
2809    State(state): State<AppState>,
2810    Path(id): Path<i64>,
2811    headers: HeaderMap,
2812    Form(form): Form<StarForm>,
2813) -> Result<Response, WebError> {
2814    let did = match current_did(&state, &headers).await {
2815        Some(d) => d,
2816        None => return Ok(Redirect::to("/login").into_response()),
2817    };
2818    let pool = &state.db;
2819
2820    let starred = matches!(
2821        form.starred.as_deref(),
2822        Some("true") | Some("1") | Some("on") | None
2823    );
2824
2825    // Refresh the caller's `sub_ref` projection, then apply the AUTHORIZED
2826    // mutation: `mark_starred` only writes when `did` subscribes to the entry's
2827    // feed. A non-subscriber gets a 404, never a mutation.
2828    resolve_subscriptions(&state, &did).await;
2829    if !store::mark_starred(pool, &did, id, starred).await? {
2830        return Ok((StatusCode::NOT_FOUND, "entry not found").into_response());
2831    }
2832
2833    // Reflect into the PDS saved-records collection. `get_entry_by_id` is scoped
2834    // to the caller's subscriptions, so this only ever acts on the caller's feed.
2835    if let Ok(Some(entry)) = get_entry_by_id(pool, &did, id).await {
2836        let entry_url = entry.url.clone().unwrap_or_default();
2837        if !entry_url.is_empty() {
2838            if starred {
2839                let mut saved = Saved::new(entry_url.clone(), now_rfc3339());
2840                saved.title = entry.title.clone();
2841                saved.feed_url = feed_url_for_id(pool, entry.feed_id).await;
2842                saved.entry_id = Some(entry.guid.clone());
2843                match state.repo().add_saved(&did, &saved).await {
2844                    Ok(rkey) => info!(%did, url = %entry_url, %rkey, "wrote saved record to PDS"),
2845                    Err(err) => warn!(%err, %did, "PDS saved write failed (starred locally)"),
2846                }
2847            } else {
2848                // Un-star: find and delete the matching saved record by URL.
2849                match state.repo().list_saved(&did).await {
2850                    Ok(records) => {
2851                        for (rkey, _rec) in records.iter().filter(|(_, r)| r.url == entry_url) {
2852                            if let Err(err) = state.repo().remove_saved(&did, rkey).await {
2853                                warn!(%err, %did, %rkey, "PDS saved delete failed");
2854                            }
2855                        }
2856                    }
2857                    Err(err) => warn!(%err, %did, "could not list saved records to un-star"),
2858                }
2859            }
2860        }
2861    }
2862
2863    if !is_htmx(&headers) {
2864        return Ok(Redirect::to("/").into_response());
2865    }
2866
2867    // Reader → out-of-band action-bar fragment; list → the row (see mark_read).
2868    if is_reader_request(&headers) {
2869        let read = entry_is_read(pool, &did, id).await?;
2870        return Ok(render(&EntryActionBarTemplate {
2871            id,
2872            read,
2873            starred,
2874            oob: true,
2875        }));
2876    }
2877
2878    let row = build_entry_row(pool, &did, id, None).await?;
2879    match row {
2880        Some(r) => Ok(render(&EntryRowTemplate { e: r })),
2881        None => Ok((StatusCode::NOT_FOUND, "entry not found").into_response()),
2882    }
2883}
2884
2885/// The feed URL for a cached feed id, if the row exists.
2886async fn feed_url_for_id(pool: &store::Pool, feed_id: i64) -> Option<String> {
2887    sqlx::query_scalar::<_, String>("SELECT url FROM feeds WHERE id = ?1")
2888        .bind(feed_id)
2889        .fetch_optional(pool)
2890        .await
2891        .ok()
2892        .flatten()
2893}
2894
2895// ---------------------------------------------------------------------------
2896// Mark-all-read
2897// ---------------------------------------------------------------------------
2898
2899/// Query for `POST /read-all` — an optional `?feed=<url>` scopes it to one feed;
2900/// absent means mark everything read.
2901#[derive(Debug, Deserialize, Default)]
2902struct ReadAllQuery {
2903    #[serde(default)]
2904    feed: Option<String>,
2905}
2906
2907/// `POST /read-all` — mark every entry read for the current DID, optionally
2908/// scoped to one feed (mark-all-read per feed or globally).
2909async fn mark_all_read(
2910    State(state): State<AppState>,
2911    headers: HeaderMap,
2912    Query(q): Query<ReadAllQuery>,
2913) -> Result<Response, WebError> {
2914    let did = match current_did(&state, &headers).await {
2915        Some(d) => d,
2916        None => return Ok(Redirect::to("/login").into_response()),
2917    };
2918    let pool = &state.db;
2919
2920    // Refresh the caller's `sub_ref` projection so the scoped mark-read writes
2921    // only ever touch feeds this DID actually subscribes to.
2922    resolve_subscriptions(&state, &did).await;
2923
2924    if let Some(feed_url) = q.feed.as_deref() {
2925        if let Ok(Some(feed)) = store::get_feed_by_url(pool, feed_url).await {
2926            store::mark_feed_read(pool, &did, feed.id, true).await?;
2927        }
2928        return Ok(Redirect::to(&format!("/?feed={}", qenc(feed_url))).into_response());
2929    }
2930
2931    // Global: mark every subscribed feed read. Fan out over the DID's feeds
2932    // (bounded by the per-DID subscription cap) using the batched per-feed path,
2933    // rather than one UPDATE round-trip per unread entry (unbounded) — same end
2934    // state, but O(feeds) statements instead of O(unread entries).
2935    for feed_id in store::subscribed_feed_ids(pool, &did).await? {
2936        store::mark_feed_read(pool, &did, feed_id, true).await?;
2937    }
2938    Ok(Redirect::to("/").into_response())
2939}
2940
2941// ---------------------------------------------------------------------------
2942// Subscribe by URL
2943// ---------------------------------------------------------------------------
2944
2945/// Flash for a URL this instance cannot store as a feed — not private, just
2946/// not a kind of feed it supports (an `at://` publication with
2947/// `FEATHERREADER_STANDARD_SITE` off, an unsupported scheme). Distinct from
2948/// [`PRIVATE_FEED_REFUSAL`], whose "not saved or sent anywhere" would be a
2949/// false promise for a record that may already exist in the user's PDS.
2950const UNSUPPORTED_FEED_URL_REFUSAL: &str =
2951    "That isn't a kind of feed this instance can subscribe to. Nothing was saved.";
2952
2953/// Shown when an OPML export is refused because the subscription list could not
2954/// be read in full.
2955///
2956/// **An empty export is worse than no export.** This path used to
2957/// `unwrap_or_default()`, so a failed read produced a 200 carrying a zero-feed
2958/// file — a blank backup, handed over at the moment the reader reached for one.
2959const EXPORT_INCOMPLETE_REFUSAL: &str =
2960    "Could not read your subscriptions in full, so nothing was exported. Your \
2961     feeds are unchanged — try again, and if it keeps failing the list may be \
2962     larger than this reader can page through.";
2963
2964/// Refusal message shown when a private/paid feed is submitted. FeatherReader
2965/// stores subscriptions in the user's PUBLIC PDS, so it supports public feeds
2966/// only for now — a private feed's secret URL is never saved, fetched, or sent
2967/// anywhere. Kept as a constant so the add and OPML paths share the exact wording
2968/// and the boot-smoke can assert on it.
2969const PRIVATE_FEED_REFUSAL: &str = "Private/paid feeds aren't supported yet. \
2970    FeatherReader stores your subscriptions in your public PDS, so it supports public \
2971    feeds for now — private-feed support arrives when atproto's private data \
2972    (permissioned records) ships. Your feed URL was not saved or sent anywhere.";
2973
2974/// Form body for `POST /subscriptions`.
2975#[derive(Debug, Deserialize)]
2976struct SubscribeForm {
2977    url: String,
2978    /// Optional folder `at://` URI to file the new feed under.
2979    #[serde(default)]
2980    folder: Option<String>,
2981}
2982
2983/// `POST /subscriptions` — subscribe by URL.
2984async fn add_subscription(
2985    State(state): State<AppState>,
2986    headers: HeaderMap,
2987    Form(form): Form<SubscribeForm>,
2988) -> Result<Response, WebError> {
2989    let did = match current_did(&state, &headers).await {
2990        Some(d) => d,
2991        None => return Ok(Redirect::to("/login").into_response()),
2992    };
2993    let pool = &state.db;
2994    let input = form.url.trim().to_string();
2995    if input.is_empty() {
2996        return Ok(Redirect::to("/").into_response());
2997    }
2998
2999    // An `at://` paste is refused here, whatever the flag says: this path must
3000    // FETCH what was pasted to find the feed in it, and nothing fetches
3001    // `at://` until the standard.site reader is wired. Letting a well-formed
3002    // one through produced "Couldn't find a feed" and a `warn!` for an
3003    // expected condition; letting a malformed one reach the privacy arm, which
3004    // fails closed as `Private`, told the reader a typo was a paid feed. Only
3005    // `at://` is pre-checked — an http(s) or scheme-less paste keeps its
3006    // "Couldn't find a feed" path below, which is the accurate answer there.
3007    // Case-insensitive, unlike the storage guards: `Url::parse` folds the
3008    // scheme, so `AT://…` would otherwise skip both this and the classifier's
3009    // at:// arm, parse as `at`, and draw the private/paid flash off the rkey.
3010    // This decides a MESSAGE; nothing about storage keys off it.
3011    if input
3012        .get(..crate::atproto::AT_URI_PREFIX.len())
3013        .is_some_and(|p| p.eq_ignore_ascii_case(crate::atproto::AT_URI_PREFIX))
3014    {
3015        info!(url = %input, %did, "refused an at:// paste: the add path cannot fetch one (not stored)");
3016        return Ok(
3017            Redirect::to(&format!("/?flash={}", qenc(UNSUPPORTED_FEED_URL_REFUSAL)))
3018                .into_response(),
3019        );
3020    }
3021
3022    // Block private/paid feeds BEFORE any fetch/resolve so a secret-bearing URL is
3023    // never even requested. Public feeds only until atproto permissioned data
3024    // ships; there is no override and nothing is stored or written.
3025    if let feed::FeedPrivacy::Private(reason) = feed::classify_feed_privacy(&input) {
3026        info!(url = %input, %reason, %did, "refused private/paid feed at add (not fetched or stored)");
3027        return Ok(
3028            Redirect::to(&format!("/?flash={}", qenc(PRIVATE_FEED_REFUSAL))).into_response(),
3029        );
3030    }
3031
3032    // Per-DID subscription cap: bound one account's storage/poller footprint on
3033    // the small box. Checked BEFORE any fetch/resolve so an over-cap account
3034    // can't even trigger an outbound request. `<= 0` disables the cap.
3035    let cap = state.config.max_subs_per_did;
3036    if cap > 0 {
3037        match store::count_subscriptions_for_did(pool, &did).await {
3038            Ok(n) if n >= cap => {
3039                info!(%did, current = n, cap, "refused subscribe: per-DID subscription cap reached");
3040                return Ok(Redirect::to(&format!(
3041                    "/?flash={}",
3042                    qenc(&format!(
3043                        "Subscription limit reached ({cap}). Remove a feed before adding another."
3044                    ))
3045                ))
3046                .into_response());
3047            }
3048            Ok(_) => {}
3049            Err(err) => warn!(%err, %did, "could not count subscriptions for cap check; allowing"),
3050        }
3051    }
3052
3053    let feed_url = match resolve_feed_url(&state.config, &input).await {
3054        Ok(u) => u,
3055        Err(err) => {
3056            warn!(%err, url = %input, "could not resolve a feed from the given URL");
3057            return Ok(Redirect::to(&format!(
3058                "/?flash={}",
3059                qenc("Couldn't find a feed at that URL")
3060            ))
3061            .into_response());
3062        }
3063    };
3064
3065    // Defensive: resolution may have discovered a feed URL that itself carries a
3066    // secret (e.g. a public site page linking a tokened feed). Re-check the
3067    // resolved URL and refuse before storing/writing anything.
3068    if let feed::FeedPrivacy::Private(reason) = feed::classify_feed_privacy(&feed_url) {
3069        info!(url = %feed_url, %reason, %did, "refused private/paid feed after resolution (not stored)");
3070        return Ok(
3071            Redirect::to(&format!("/?flash={}", qenc(PRIVATE_FEED_REFUSAL))).into_response(),
3072        );
3073    }
3074
3075    // The URL about to be STORED is what must be storable — not the one the
3076    // user typed. Autodiscovery already yields only http(s), but this is the
3077    // path that writes the row and the PDS record, so the check lives here too:
3078    // the same gate the OPML and rename paths apply, on the same terms.
3079    if !feed::is_storable_feed_url(&feed_url, state.config.standard_site) {
3080        info!(url = %feed_url, %did, "refused unsupported feed URL after resolution (not stored)");
3081        return Ok(
3082            Redirect::to(&format!("/?flash={}", qenc(UNSUPPORTED_FEED_URL_REFUSAL)))
3083                .into_response(),
3084        );
3085    }
3086
3087    // Global feeds ceiling: a brand-new distinct feed is refused once the shared
3088    // cache is full (an existing/duplicate feed URL is always fine — it adds no
3089    // row). Bounds total cache size across all users on the box. `<= 0` disables.
3090    let feeds_cap = state.config.max_feeds_global;
3091    if feeds_cap > 0 && store::get_feed_by_url(pool, &feed_url).await?.is_none() {
3092        match store::count_feeds(pool).await {
3093            Ok(n) if n >= feeds_cap => {
3094                warn!(%did, feeds = n, cap = feeds_cap, feed = %feed_url, "refused subscribe: global feeds ceiling reached");
3095                return Ok(Redirect::to(&format!(
3096                    "/?flash={}",
3097                    qenc(
3098                        "This instance is at its feed capacity right now. Please try again later."
3099                    )
3100                ))
3101                .into_response());
3102            }
3103            Ok(_) => {}
3104            Err(err) => warn!(%err, "could not count feeds for global-cap check; allowing"),
3105        }
3106    }
3107
3108    store::upsert_feed(
3109        pool,
3110        &store::NewFeed {
3111            url: feed_url.clone(),
3112            ..Default::default()
3113        },
3114    )
3115    .await?;
3116
3117    if let Ok(client) = feed::build_client() {
3118        if let Some(feed_row) = store::get_feed_by_url(pool, &feed_url).await? {
3119            match feed::poll_feed(pool, &client, &feed_row, state.config.max_entries_per_feed).await
3120            {
3121                Ok(outcome) => {
3122                    info!(feed = %feed_url, ?outcome, "polled new subscription");
3123                    // **This path is not the scheduler, so it must settle the
3124                    // error columns itself.** `poll_feed` writes validators and
3125                    // `last_polled` and nothing else.
3126                    feed::settle_poll(pool, &feed_url, &outcome, state.config.poll_interval).await;
3127                }
3128                Err(err) => warn!(%err, feed = %feed_url, "initial poll failed"),
3129            }
3130        }
3131    }
3132
3133    let mut sub = Subscription::new(feed_url.clone(), now_rfc3339());
3134    if let Ok(Some(feed_row)) = store::get_feed_by_url(pool, &feed_url).await {
3135        sub.title = feed_row.title.clone();
3136        sub.site_url = feed_row.site_url.clone();
3137    }
3138    sub.folder = form
3139        .folder
3140        .map(|f| f.trim().to_string())
3141        .filter(|f| !f.is_empty());
3142
3143    match state.repo().add_subscription(&did, &sub).await {
3144        Ok(rkey) => info!(feed = %feed_url, %rkey, %did, "wrote subscription record to PDS"),
3145        Err(err) => {
3146            warn!(%err, feed = %feed_url, %did, "PDS subscription write failed (cached locally)")
3147        }
3148    }
3149
3150    Ok(Redirect::to("/").into_response())
3151}
3152
3153/// `POST /subscriptions/:rkey/delete` — unsubscribe (delete the PDS record).
3154async fn delete_subscription(
3155    State(state): State<AppState>,
3156    headers: HeaderMap,
3157    Path(rkey): Path<String>,
3158) -> Result<Response, WebError> {
3159    let did = match current_did(&state, &headers).await {
3160        Some(d) => d,
3161        None => return Ok(Redirect::to("/login").into_response()),
3162    };
3163    match state.repo().remove_subscription(&did, &rkey).await {
3164        Ok(()) => info!(%did, %rkey, "unsubscribed (deleted PDS subscription record)"),
3165        Err(err) => warn!(%err, %did, %rkey, "PDS unsubscribe failed"),
3166    }
3167    Ok(Redirect::to("/").into_response())
3168}
3169
3170/// Form body for `POST /subscriptions/:rkey/rename`.
3171#[derive(Debug, Deserialize)]
3172struct RenameSubForm {
3173    url: String,
3174    #[serde(default)]
3175    title: Option<String>,
3176    #[serde(default)]
3177    site_url: Option<String>,
3178    #[serde(default)]
3179    folder: Option<String>,
3180}
3181
3182/// `POST /subscriptions/:rkey/rename` — retitle a feed and/or move it to a
3183/// folder, rewriting the whole subscription record via `putRecord`.
3184async fn rename_subscription(
3185    State(state): State<AppState>,
3186    headers: HeaderMap,
3187    Path(rkey): Path<String>,
3188    Form(form): Form<RenameSubForm>,
3189) -> Result<Response, WebError> {
3190    let did = match current_did(&state, &headers).await {
3191        Some(d) => d,
3192        None => return Ok(Redirect::to("/login").into_response()),
3193    };
3194    let feed_url = form.url.trim().to_string();
3195
3196    // Reject an empty/blank resolved URL — a rename with no usable URL must not
3197    // write a junk row to the cache or a malformed subscription record to the
3198    // PDS (add_subscription refuses an empty input the same way).
3199    if feed_url.is_empty() {
3200        return Ok(Redirect::to("/").into_response());
3201    }
3202
3203    // **Read before write — `update_subscription` is a `putRecord`, and a
3204    // putRecord replaces the WHOLE record** (see its doc on `atproto.rs`).
3205    //
3206    // This used to build a fresh `Subscription::new(feed_url, now_rfc3339())`
3207    // and hand that over, so every field the form does not carry was written
3208    // back as its default. `templates/manage_row.html` posts `url`, `title` and
3209    // `folder` — and nothing else — so a rename silently destroyed four fields:
3210    // `siteUrl`, `fetchHint`, `private`, and `createdAt`.
3211    //
3212    // `createdAt` is the one that matters most: it is the reader's subscribe
3213    // time, it is the sort key for "when did I subscribe", it lives in THEIR
3214    // repo rather than our cache, and once overwritten it is gone with nothing
3215    // in the UI to say so.
3216    //
3217    // There is no single-record read on `Repo` (no `getRecord`), so this lists
3218    // and filters. That is one extra round trip on an action that is already
3219    // doing a PDS write, and it is bounded; a `get_subscription` would be
3220    // strictly better if this ever measures badly.
3221    //
3222    // **A failed read refuses the rename.** Falling back to the old
3223    // rebuild-from-scratch here would reinstate the data loss on exactly the
3224    // flaky path, which is the worst place to have it. The write below already
3225    // takes this stance — "a failure here means nothing was renamed or moved" —
3226    // and the read gets the same one.
3227    let existing = match state.repo().list_subscriptions_sorted(&did).await {
3228        Ok(subs) => subs.into_iter().find(|(k, _)| *k == rkey).map(|(_, s)| s),
3229        Err(err) => {
3230            warn!(%err, %did, %rkey, "could not read the subscription before renaming it");
3231            return Ok(Redirect::to(&format!(
3232                "/?flash={}",
3233                qenc("Could not reach your PDS — nothing was renamed or moved.")
3234            ))
3235            .into_response());
3236        }
3237    };
3238    let Some(existing) = existing else {
3239        // The rkey is not in the reader's repo. Renaming a record that is not
3240        // there would CREATE one, which is not what "rename" means and would
3241        // give it a fresh `createdAt` — the bug this read exists to prevent.
3242        warn!(%did, %rkey, "refused rename: no such subscription in the repo");
3243        return Ok(Redirect::to(&format!(
3244            "/?flash={}",
3245            qenc("That subscription is no longer in your repo — nothing was renamed or moved.")
3246        ))
3247        .into_response());
3248    };
3249
3250    // The subscription can be repointed at a different feed URL. **Every gate
3251    // on the URL applies to a repoint and only a repoint** — the three below
3252    // were each, at one time, run before this line on the URL as posted, and
3253    // each refused a pure retitle of a record that already existed:
3254    //
3255    // - privacy: the narrowed at:// arm fails closed as `Private` for an
3256    //   at-URI that is not a publication (a feed generator another client
3257    //   subscribed to), so the record became un-editable with a flash saying
3258    //   it "was not saved or sent anywhere";
3259    // - the global feeds ceiling keyed on "URL not in the cache", and an
3260    //   at:// record is never cached with the flag off, so at capacity a
3261    //   retitle was refused for a row the handler would not insert;
3262    // - storability, the same way.
3263    //
3264    // An unchanged URL is already in the reader's repo; refusing to retitle
3265    // it protects nothing and takes their own record away from them.
3266    // Like for like: the form value is trimmed, and a record another client
3267    // wrote may carry padding — compared raw, every retitle of it was a repoint.
3268    let url_changed = existing.url.trim() != feed_url;
3269
3270    // **Storability, on the same terms as the add and OPML paths — for a
3271    // REPOINT, and FIRST.** A target this instance cannot store gets that
3272    // answer, not "private" (the at:// arm fails closed) or "at capacity"
3273    // (it would never be inserted) — the ordering the add path has. This handler writes `feeds` via `upsert_feed` and had only the
3274    // privacy check above, so `FEATHERREADER_STANDARD_SITE` was bypassable
3275    // here; a review found it by enumerating every writer of the table. The
3276    // first fix ran this check before the repo lookup, on the URL as posted —
3277    // which refused a pure retitle of a subscription that already IS an
3278    // at-URI, on every instance with the flag off. The flag gates what the
3279    // cache may store, not whether a reader may edit their own record: an
3280    // unchanged non-storable URL keeps its PDS write and simply gets no cache
3281    // row below.
3282    let storable = feed::is_storable_feed_url(&feed_url, state.config.standard_site);
3283    if url_changed && !storable {
3284        info!(url = %feed_url, %did, %rkey, "refused a repoint to a non-storable feed URL");
3285        return Ok(
3286            Redirect::to(&format!("/?flash={}", qenc(UNSUPPORTED_FEED_URL_REFUSAL)))
3287                .into_response(),
3288        );
3289    }
3290
3291    // Block private/paid feeds on a repoint. `url` is attacker-controllable,
3292    // and rename both upserts it to the local cache AND rewrites the PDS
3293    // subscription record (a public `putRecord`), so without this guard a
3294    // crafted rename could land a secret-bearing URL in the public PDS — the
3295    // exact leak the add and OPML paths already prevent.
3296    if url_changed {
3297        if let feed::FeedPrivacy::Private(reason) = feed::classify_feed_privacy(&feed_url) {
3298            info!(url = %feed_url, %reason, %did, %rkey, "refused private/paid feed at rename (not stored or written)");
3299            return Ok(
3300                Redirect::to(&format!("/?flash={}", qenc(PRIVATE_FEED_REFUSAL))).into_response(),
3301            );
3302        }
3303    }
3304
3305    // Global feeds ceiling parity with add_subscription: a repoint to a
3306    // brand-new feed URL would insert a NEW `feeds` row. Refuse that when the
3307    // shared cache is at capacity (an existing/duplicate URL adds no row and
3308    // is always fine). `<= 0` disables.
3309    let feeds_cap = state.config.max_feeds_global;
3310    if url_changed
3311        && feeds_cap > 0
3312        && store::get_feed_by_url(&state.db, &feed_url)
3313            .await?
3314            .is_none()
3315    {
3316        match store::count_feeds(&state.db).await {
3317            Ok(n) if n >= feeds_cap => {
3318                warn!(%did, %rkey, feeds = n, cap = feeds_cap, feed = %feed_url, "refused rename: global feeds ceiling reached");
3319                return Ok(Redirect::to(&format!(
3320                    "/?flash={}",
3321                    qenc(
3322                        "This instance is at its feed capacity right now. Please try again later."
3323                    )
3324                ))
3325                .into_response());
3326            }
3327            Ok(_) => {}
3328            Err(err) => warn!(%err, "could not count feeds for global-cap check; allowing"),
3329        }
3330    }
3331
3332    let mut sub = existing;
3333    sub.url = feed_url;
3334    sub.title = form
3335        .title
3336        .map(|t| t.trim().to_string())
3337        .filter(|t| !t.is_empty());
3338    sub.folder = form
3339        .folder
3340        .map(|f| f.trim().to_string())
3341        .filter(|f| !f.is_empty());
3342    // `createdAt` and `private` carry over untouched — neither is a property of
3343    // which feed URL the subscription points at.
3344    //
3345    // `siteUrl` and `fetchHint` ARE properties of the specific feed, so a
3346    // repoint drops them rather than leaving a site link for the old feed
3347    // hanging off the new one. An explicit form value still wins if the form
3348    // ever starts carrying one.
3349    match form
3350        .site_url
3351        .map(|t| t.trim().to_string())
3352        .filter(|t| !t.is_empty())
3353    {
3354        Some(site) => sub.site_url = Some(site),
3355        None if url_changed => sub.site_url = None,
3356        None => {}
3357    }
3358    if url_changed {
3359        sub.fetch_hint = None;
3360    }
3361
3362    // Keep the local cache title in step for the loose-feed fallback path —
3363    // for a row this instance would have. Two cases write nothing:
3364    //
3365    // - not storable (an existing at-URI with the flag off): the record is the
3366    //   reader's to edit, the cache row is not this instance's to create;
3367    // - an unchanged URL with no cache row: a retitle is never the write that
3368    //   CREATES a row. That covers two findings at once — the ceiling is
3369    //   checked on a repoint only, so a retitle must not insert past it; and
3370    //   a secret-bearing URL another client subscribed to has no row (the
3371    //   privacy gate above runs on a repoint only, and `resolve_subscriptions`
3372    //   refuses to cache it), so it cannot enter the shared table here, be
3373    //   polled, fail, and be printed on the admin page. A privacy re-check on
3374    //   this write was the first draft; mutation showed it dead — the row
3375    //   rule already refused every case it would have.
3376    let cache_write =
3377        storable && (url_changed || store::get_feed_by_url(&state.db, &sub.url).await?.is_some());
3378    if !cache_write {
3379        info!(%did, %rkey, url = %sub.url, "renamed a subscription without touching the cache");
3380    } else if let Err(err) = store::upsert_feed(
3381        &state.db,
3382        &store::NewFeed {
3383            url: sub.url.clone(),
3384            title: sub.title.clone(),
3385            site_url: sub.site_url.clone(),
3386            ..Default::default()
3387        },
3388    )
3389    .await
3390    {
3391        // Not fatal to the rename — the PDS record below is the source of truth
3392        // — but a missing `feeds` row means this subscription is never polled.
3393        warn!(%err, %did, url = %sub.url, "could not update the cached feed row on rename");
3394    }
3395
3396    // **The PDS write decides what the reader is told.**
3397    //
3398    // This used to `warn!` on failure and then redirect exactly as it does on
3399    // success, so a rename that did not happen was indistinguishable from one
3400    // that did — the reader saw their old title come back and had no reason to
3401    // think anything had gone wrong. The PDS record IS the subscription; a
3402    // failure here means nothing was renamed or moved.
3403    match state.repo().update_subscription(&did, &rkey, &sub).await {
3404        Ok(res) => {
3405            info!(%did, %rkey, uri = %res.uri, "renamed/moved subscription");
3406            Ok(Redirect::to("/").into_response())
3407        }
3408        Err(err) => {
3409            warn!(%err, %did, %rkey, "PDS subscription update failed");
3410            Ok(Redirect::to(&format!(
3411                "/?flash={}",
3412                qenc("Could not save that change to your PDS — nothing was renamed or moved.")
3413            ))
3414            .into_response())
3415        }
3416    }
3417}
3418
3419// ---------------------------------------------------------------------------
3420// Folders
3421// ---------------------------------------------------------------------------
3422
3423/// Form body for `POST /folders`.
3424#[derive(Debug, Deserialize)]
3425struct FolderForm {
3426    name: String,
3427}
3428
3429/// `POST /folders` — create a folder record.
3430async fn create_folder(
3431    State(state): State<AppState>,
3432    headers: HeaderMap,
3433    Form(form): Form<FolderForm>,
3434) -> Result<Response, WebError> {
3435    let did = match current_did(&state, &headers).await {
3436        Some(d) => d,
3437        None => return Ok(Redirect::to("/login").into_response()),
3438    };
3439    let name = form.name.trim();
3440    if name.is_empty() {
3441        return Ok(Redirect::to("/").into_response());
3442    }
3443    let folder = Folder::new(name.to_string(), now_rfc3339());
3444    match state.repo().add_folder(&did, &folder).await {
3445        Ok(rkey) => info!(%did, %rkey, name, "created folder record"),
3446        Err(err) => warn!(%err, %did, "PDS folder create failed"),
3447    }
3448    Ok(Redirect::to("/").into_response())
3449}
3450
3451/// `POST /folders/:rkey/rename` — rename a folder record.
3452async fn rename_folder(
3453    State(state): State<AppState>,
3454    headers: HeaderMap,
3455    Path(rkey): Path<String>,
3456    Form(form): Form<FolderForm>,
3457) -> Result<Response, WebError> {
3458    let did = match current_did(&state, &headers).await {
3459        Some(d) => d,
3460        None => return Ok(Redirect::to("/login").into_response()),
3461    };
3462    let name = form.name.trim();
3463    if name.is_empty() {
3464        return Ok(Redirect::to("/").into_response());
3465    }
3466    let folder = Folder::new(name.to_string(), now_rfc3339());
3467    match state.repo().rename_folder(&did, &rkey, &folder).await {
3468        Ok(res) => info!(%did, %rkey, uri = %res.uri, "renamed folder"),
3469        Err(err) => warn!(%err, %did, %rkey, "PDS folder rename failed"),
3470    }
3471    Ok(Redirect::to("/").into_response())
3472}
3473
3474/// `POST /folders/:rkey/delete` — delete a folder record (feeds referencing it
3475/// simply become un-foldered).
3476async fn delete_folder(
3477    State(state): State<AppState>,
3478    headers: HeaderMap,
3479    Path(rkey): Path<String>,
3480) -> Result<Response, WebError> {
3481    let did = match current_did(&state, &headers).await {
3482        Some(d) => d,
3483        None => return Ok(Redirect::to("/login").into_response()),
3484    };
3485    match state.repo().remove_folder(&did, &rkey).await {
3486        Ok(()) => info!(%did, %rkey, "deleted folder record"),
3487        Err(err) => warn!(%err, %did, %rkey, "PDS folder delete failed"),
3488    }
3489    Ok(Redirect::to("/").into_response())
3490}
3491
3492/// Resolve a user-pasted URL to a canonical feed URL: if fetching it yields a
3493/// feed document we take it as-is; if it yields an HTML page we run
3494/// autodiscovery over its `<link rel="alternate">` tags.
3495async fn resolve_feed_url(_config: &Config, input: &str) -> anyhow::Result<String> {
3496    let parsed =
3497        url::Url::parse(input).map_err(|e| anyhow::anyhow!("not a valid URL {input:?}: {e}"))?;
3498
3499    let client = feed::build_client()?;
3500    // Fetch through the SSRF guard: scheme + resolved-IP checks on the URL and
3501    // every redirect hop, so a user-pasted URL can't reach cloud metadata /
3502    // loopback / private hosts.
3503    let resp = crate::net::guarded_get(&client, parsed.as_str(), &[]).await?;
3504    let final_url = resp.url().clone();
3505    let content_type = resp
3506        .headers()
3507        .get(axum::http::header::CONTENT_TYPE)
3508        .and_then(|v| v.to_str().ok())
3509        .unwrap_or("")
3510        .to_ascii_lowercase();
3511    // Cap the body (streamed, aborts over 8 MiB) — never trust Content-Length,
3512    // gzip strips it, and this response is reflected into the UI.
3513    let raw = crate::net::read_capped(resp).await?;
3514    let body = String::from_utf8_lossy(&raw).into_owned();
3515
3516    let looks_like_feed = content_type.contains("xml")
3517        || content_type.contains("rss")
3518        || content_type.contains("atom")
3519        || content_type.contains("application/feed+json")
3520        || {
3521            let head = body.trim_start();
3522            head.starts_with("<?xml")
3523                || head.starts_with("<rss")
3524                || head.starts_with("<feed")
3525                || head.contains("<rss")
3526                || head.contains("<feed")
3527        };
3528    if looks_like_feed {
3529        return Ok(final_url.to_string());
3530    }
3531
3532    match feed::discover_feed(&body, Some(&final_url)) {
3533        Some(u) => Ok(u.to_string()),
3534        None => anyhow::bail!("no feed found at {input} (no autodiscovery link)"),
3535    }
3536}
3537
3538// ---------------------------------------------------------------------------
3539// Login (atproto OAuth via the sidecar)
3540// ---------------------------------------------------------------------------
3541
3542/// Query for `GET /login`.
3543#[derive(Debug, Deserialize, Default)]
3544struct LoginQuery {
3545    #[serde(default)]
3546    handle: Option<String>,
3547    #[serde(default)]
3548    error: Option<String>,
3549    #[serde(default)]
3550    flash: Option<String>,
3551}
3552
3553/// `GET /login` — start the atproto OAuth flow, or render the handle form.
3554///
3555/// **Pre-handshake gate:** starting OAuth (a `?handle=` GET) is refused unless
3556/// the visitor is allowed by [`may_start_oauth`] — an existing beta seat (via
3557/// session cookie *or* the submitted handle resolving to a seated DID) or a
3558/// valid reserving invite cookie. Refusal redirects to `/beta/redeem`. The bare
3559/// form (no handle) always renders.
3560async fn login_form(
3561    State(state): State<AppState>,
3562    headers: HeaderMap,
3563    Query(q): Query<LoginQuery>,
3564) -> Response {
3565    if let Some(handle) = q
3566        .handle
3567        .map(|h| h.trim().to_string())
3568        .filter(|h| !h.is_empty())
3569    {
3570        if !may_start_oauth(&state, &headers, &handle).await {
3571            return Redirect::to("/beta/redeem").into_response();
3572        }
3573        return start_oauth(&state, &handle).await;
3574    }
3575    render(&LoginTemplate {
3576        repo_url: REPO_URL,
3577        error: q.error.unwrap_or_default(),
3578        flash: q.flash.unwrap_or_default(),
3579    })
3580}
3581
3582/// `POST /login` — the handle-form submit: redirect into the sidecar OAuth flow.
3583/// Subject to the same pre-handshake invite gate as `GET /login?handle=`.
3584async fn login_submit(
3585    State(state): State<AppState>,
3586    headers: HeaderMap,
3587    Form(form): Form<LoginForm>,
3588) -> Response {
3589    let handle = form.handle.trim();
3590    if handle.is_empty() {
3591        return login_error("Enter your atproto handle.");
3592    }
3593    if !may_start_oauth(&state, &headers, handle).await {
3594        return Redirect::to("/beta/redeem").into_response();
3595    }
3596    start_oauth(&state, handle).await
3597}
3598
3599/// Whether this visitor is allowed to *start* the OAuth handshake. The gate
3600/// admits, in order of cost:
3601///
3602/// 1. an existing beta member's cookie session whose DID already holds a seat;
3603/// 2. a fresh visitor carrying a valid reserving invite cookie;
3604/// 3. a cookie-less visitor whose submitted `handle` resolves to a DID that
3605///    already holds a seat — this honors the **seeded admin's first login** on a
3606///    fresh deploy (and any returning member who cleared cookies) without a
3607///    session cookie or an invite code.
3608///
3609/// The cookie/invite fast paths run FIRST and short-circuit, so the network
3610/// handle→DID resolution is only attempted when neither applies. It fails
3611/// CLOSED: a malformed/unresolvable handle, a resolution error/timeout, or a
3612/// resolved DID with no seat all leave the visitor bounced to `/beta/redeem`.
3613/// This keeps the anti-abuse intent — a rando now pays a cheap handle
3614/// resolution instead of a burned sidecar handshake (and `/login` is already in
3615/// the rate-limited path set).
3616async fn may_start_oauth(state: &AppState, headers: &HeaderMap, handle: &str) -> bool {
3617    // The production resolver is the app's existing atproto handle→DID path,
3618    // routed through the SSRF guard. Resolution is injected so tests can exercise
3619    // the gate without a live network call (the guard forbids loopback mocks).
3620    may_start_oauth_with(state, headers, handle, |h| async move {
3621        crate::atproto::resolve_handle(&state.http, &state.config.resolver_base, &h)
3622            .await
3623            .ok()
3624    })
3625    .await
3626}
3627
3628/// Core of [`may_start_oauth`] with the handle→DID resolver injected as `resolve`
3629/// (returning `Some(did)` on success, `None` on any failure/unresolvable handle).
3630/// The cookie + invite fast paths run FIRST and short-circuit, so `resolve` is
3631/// only called when neither admits — keeping the network round-trip off the hot
3632/// path and preserving the fail-closed contract on resolution failure.
3633async fn may_start_oauth_with<F, Fut>(
3634    state: &AppState,
3635    headers: &HeaderMap,
3636    handle: &str,
3637    resolve: F,
3638) -> bool
3639where
3640    F: FnOnce(String) -> Fut,
3641    Fut: std::future::Future<Output = Option<String>>,
3642{
3643    // 1. An already-beta'd session may re-auth freely.
3644    if let Some(did) = current_did(state, headers).await {
3645        if store::has_beta_access(&state.db, &did)
3646            .await
3647            .unwrap_or(false)
3648        {
3649            return true;
3650        }
3651    }
3652    // 2. A valid reserving invite cookie.
3653    if invite_cookie_code(headers, &state.config.cookie_secret).is_some() {
3654        return true;
3655    }
3656    // 3. Cookie-less: honor an existing seat by resolving the submitted handle to
3657    //    a DID (the seeded-admin first-login / cleared-cookies case). Fail closed
3658    //    on any resolution error or unresolvable/malformed handle.
3659    match resolve(handle.to_string()).await {
3660        Some(did) => store::has_beta_access(&state.db, &did)
3661            .await
3662            .unwrap_or(false),
3663        None => {
3664            warn!(%handle, "handle resolution failed in pre-handshake beta gate");
3665            false
3666        }
3667    }
3668}
3669
3670/// Begin the OAuth handshake for `handle`, on whichever backend is live.
3671///
3672/// **On `form-action 'self'` and this redirect.** The Rust arm answers a form
3673/// POST with a redirect straight to the PDS — cross-origin — while the app's CSP
3674/// carries `form-action 'self'`. Browsers have historically disagreed about
3675/// whether that directive applies to redirects following a form submission, and
3676/// if it did here, login would break in a browser while every test passed.
3677///
3678/// It does not, and the evidence is the SIDECAR path, which is live in
3679/// production today: `POST /login` -> 303 to the same-origin `/oauth/login` ->
3680/// 302 to the PDS, cross-origin, under this same CSP. A browser checking the
3681/// whole redirect chain would already be blocking that. One checking only the
3682/// form's action URL sees `/login` in both cases. The two arms differ only in
3683/// how many same-origin hops precede the cross-origin one, so any policy that
3684/// permits the sidecar flow permits this one.
3685///
3686/// The two arms differ in SHAPE, not just in implementation. The sidecar owns
3687/// its own `/login` and its own callback, so starting a login is one redirect
3688/// and nothing is stored here. The Rust backend pushes the authorization
3689/// request itself, which means this app now holds the pending login — and must
3690/// set the browser-binding cookie that the callback will be checked against.
3691async fn start_oauth(state: &AppState, handle: &str) -> Response {
3692    match state.config.repo_backend {
3693        crate::metrics::Backend::Sidecar => {
3694            let url = state.sidecar.login_url(handle, None);
3695            info!(%handle, "redirecting to OAuth sidecar login");
3696            Redirect::to(&url).into_response()
3697        }
3698        crate::metrics::Backend::Rust => {
3699            let Some(runtime) = state.oauth.as_deref() else {
3700                warn!("the rust backend is live but its OAuth runtime is absent");
3701                return login_error("Login is not available right now.");
3702            };
3703            match crate::oauth::login::start(
3704                runtime,
3705                &state.http,
3706                &state.db,
3707                handle,
3708                crate::store::now_unix(),
3709            )
3710            .await
3711            {
3712                Ok(started) => {
3713                    info!(%handle, "pushed authorization request; redirecting to the PDS");
3714                    let mut resp = Redirect::to(&started.authorize_url).into_response();
3715                    set_cookie(
3716                        &mut resp,
3717                        &cookie::sign_value(
3718                            OAUTH_BINDING_COOKIE,
3719                            &started.binding_token,
3720                            &state.config.cookie_secret,
3721                            OAUTH_BINDING_MAX_AGE_SECS,
3722                        ),
3723                    );
3724                    resp
3725                }
3726                Err(err) => {
3727                    // The handle the user typed is logged; the error is not shown
3728                    // to them verbatim, since it can name internal hosts.
3729                    warn!(%err, %handle, "could not start the OAuth login");
3730                    login_error("Could not start login for that handle.")
3731                }
3732            }
3733        }
3734    }
3735}
3736
3737/// Clear the browser-binding cookie. Called on every terminal outcome of a
3738/// callback, successful or not: the pending row is consumed either way, so a
3739/// lingering cookie can only ever match a login that no longer exists.
3740fn clear_binding_cookie(resp: &mut Response) {
3741    set_cookie(
3742        resp,
3743        &format!("{OAUTH_BINDING_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
3744    );
3745}
3746
3747/// Form body for `POST /login`.
3748#[derive(Debug, Deserialize)]
3749struct LoginForm {
3750    handle: String,
3751}
3752
3753/// Query for `GET /oauth/callback`.
3754///
3755/// Carries BOTH shapes, because the two backends deliver different things to
3756/// the same URL: the sidecar hands back a one-shot `session_id` it has already
3757/// exchanged, while the PDS redirects here directly with `code`/`state`/`iss`
3758/// for this app to exchange itself. Which fields are populated is decided by
3759/// which backend started the login, not by which is live now — so a flip with a
3760/// login already in flight still lands in the right arm.
3761#[derive(Debug, Deserialize, Default)]
3762struct CallbackQuery {
3763    /// Sidecar backend: the handoff id.
3764    #[serde(default)]
3765    session_id: Option<String>,
3766    /// Rust backend: the authorization code and its envelope.
3767    #[serde(default)]
3768    code: Option<String>,
3769    #[serde(default)]
3770    state: Option<String>,
3771    #[serde(default)]
3772    iss: Option<String>,
3773    /// JARM, which is not supported — carried only so it can be refused
3774    /// explicitly rather than read as "no code".
3775    #[serde(default)]
3776    response: Option<String>,
3777    #[serde(default)]
3778    error: Option<String>,
3779    #[serde(default)]
3780    error_description: Option<String>,
3781}
3782
3783/// `GET /oauth/callback` — establish the cookie session.
3784///
3785/// **Invite gate:** the verified DID must hold beta access. If it already does
3786/// (existing member / seeded admin) it's admitted directly. Otherwise we bind
3787/// the DID to the reserved invite cookie: `redeem_code` atomically consumes the
3788/// code and grants the seat. A DID with neither is bounced to `/beta/redeem`.
3789async fn oauth_callback(
3790    State(state): State<AppState>,
3791    headers: HeaderMap,
3792    Query(q): Query<CallbackQuery>,
3793) -> Response {
3794    // An error response is handled by the SAME arm that would have handled a
3795    // success, not short-circuited here.
3796    //
3797    // Returning early looks obviously right and is wrong on the Rust path: it
3798    // skips `verify_callback`, which validates `iss` BEFORE reporting the error
3799    // precisely because RFC 9207 §2.4 says a client "MUST NOT assume that the
3800    // error originates from the intended AS". It also leaves the pending row
3801    // unconsumed, so a `state` that has already produced a callback stays usable
3802    // until it expires.
3803    //
3804    // The sidecar arm has no such check to reach, so it is short-circuited
3805    // below, preserving exactly what it did before.
3806    // **The arm is chosen by what the SERVER knows, not by what the caller
3807    // sent.** A `session_id` in the query used to select the sidecar arm on its
3808    // own — so a caller could pick which code path ran, and the sidecar arm has
3809    // no browser-binding check at all. It also short-circuited the error path
3810    // below, skipping the `iss` validation.
3811    //
3812    // Requiring the Rust runtime to be absent, or a sidecar backend to be the
3813    // configured one, means the selection follows this deployment's own
3814    // configuration. A login started before a flip still completes, because the
3815    // Rust arm is reached whenever the Rust runtime exists and can match the
3816    // `state` against a pending row it actually wrote.
3817    // The sidecar hands off in TWO shapes, not one: `?session_id=…` on success
3818    // and `?error=…&error_description=…` on its own failure. Keying only on
3819    // `session_id` sent the failure shape down the Rust arm, which then failed
3820    // with "no `state`" and replaced the specific reason with a generic one —
3821    // and `error_description` is exactly what the sidecar Caddy routing matches
3822    // to send that request here in the first place.
3823    let sidecar_shape =
3824        q.session_id.as_deref().is_some_and(|s| !s.is_empty()) || q.error_description.is_some();
3825    let sidecar_handoff = sidecar_shape
3826        && (state.oauth.is_none() || state.config.repo_backend == crate::metrics::Backend::Sidecar);
3827    if let Some(err) = q.error.clone() {
3828        // **Neither the code nor the description is echoed as sent.**
3829        //
3830        // Both are server-controlled free text arriving on a public GET, so
3831        // anyone who can make a browser fetch this URL chooses them. The raw
3832        // `error` used to go into a `warn!` AND into the rendered login page,
3833        // and `error_description` — arbitrary text, newlines included — went
3834        // into the log verbatim: a log-injection surface on one side and
3835        // attacker-chosen copy in the product's own voice on the other.
3836        //
3837        // `oauth::flow` already decided this exact question for the Rust arm:
3838        // reduce the code to a known slug, drop the description entirely. That
3839        // reasoning is not specific to which arm handles the callback, and this
3840        // one simply never got the same treatment. The description's LENGTH is
3841        // kept, because "the server sent a 4 KB explanation" is occasionally
3842        // worth knowing and cannot be used to inject anything.
3843        let slug = crate::oauth::flow::known_error_slug(&err);
3844        warn!(
3845            error = slug,
3846            desc_len = q.error_description.as_deref().map_or(0, str::len),
3847            "OAuth callback returned an error"
3848        );
3849        if sidecar_handoff || state.oauth.is_none() {
3850            return login_error(&format!("Login failed: {slug}"));
3851        }
3852        // Fall through: the Rust arm consumes the pending row and validates
3853        // `iss` against it, and reports the failure afterwards.
3854    }
3855
3856    // Which arm runs is decided by WHAT ARRIVED, not by which backend is
3857    // currently selected: a login started before a flip must still complete.
3858    let session = if sidecar_handoff {
3859        let session_id = q.session_id.clone().unwrap_or_default();
3860        match state.sidecar.resolve_session(&session_id).await {
3861            Ok(Some(s)) => s,
3862            Ok(None) => {
3863                warn!("OAuth callback session_id did not resolve (expired/unknown)");
3864                return login_error("Login session expired — please try again.");
3865            }
3866            Err(err) => {
3867                warn!(%err, "failed to resolve OAuth session via the sidecar");
3868                return login_error("Login failed talking to the auth service.");
3869            }
3870        }
3871    } else {
3872        let Some(runtime) = state.oauth.as_deref() else {
3873            warn!("an OAuth callback arrived with no sidecar session and no Rust runtime");
3874            return login_error("Login failed: this login could not be completed.");
3875        };
3876        let params = crate::oauth::flow::CallbackParams {
3877            code: q.code.clone(),
3878            state: q.state.clone(),
3879            iss: q.iss.clone(),
3880            // Passed through, NOT dropped: `verify_callback` checks `iss`
3881            // against the pending row's issuer before it reports the error, and
3882            // it cannot do that for an error it never sees.
3883            error: q.error.clone(),
3884            error_description: q.error_description.clone(),
3885            response: q.response.clone(),
3886        };
3887        let binding =
3888            cookie::verify_value(&headers, OAUTH_BINDING_COOKIE, &state.config.cookie_secret);
3889        match crate::oauth::login::complete(
3890            runtime,
3891            &state.http,
3892            &state.db,
3893            &params,
3894            binding.as_deref(),
3895            crate::store::now_unix(),
3896        )
3897        .await
3898        {
3899            Ok(done) => crate::atproto::SidecarSession {
3900                did: done.did,
3901                handle: done.handle,
3902            },
3903            Err(err) => {
3904                // Never echoed to the browser: the message can name the issuer,
3905                // the PDS, and why a binding check failed.
3906                warn!(%err, "could not complete the OAuth callback");
3907                let mut resp = login_error("Login failed — please try again.");
3908                clear_binding_cookie(&mut resp);
3909                return resp;
3910            }
3911        }
3912    };
3913
3914    // Bind the verified DID to the invite gate. Returns a response only on the
3915    // (rare) failure paths; `Ok(())` means the DID now holds beta access.
3916    let mut clear_invite = false;
3917    if !store::has_beta_access(&state.db, &session.did)
3918        .await
3919        .unwrap_or(false)
3920    {
3921        // Not yet a member: consume the reserved invite code, if any.
3922        let code = match invite_cookie_code(&headers, &state.config.cookie_secret) {
3923            Some(c) => c,
3924            None => {
3925                warn!(did = %session.did, "OAuth callback with no beta access and no invite cookie");
3926                return Redirect::to("/beta/redeem").into_response();
3927            }
3928        };
3929        match store::redeem_code(
3930            &state.db,
3931            &code,
3932            &session.did,
3933            session.handle.as_deref(),
3934            state.config.beta_cap,
3935        )
3936        .await
3937        {
3938            Ok(Ok(())) => {
3939                clear_invite = true;
3940                info!(did = %session.did, "invite code redeemed at OAuth callback; beta access granted");
3941            }
3942            Ok(Err(policy)) => {
3943                warn!(did = %session.did, ?policy, "invite redeem failed at callback");
3944                let mut resp = redeem_bounce(&policy).into_response();
3945                // The reservation is spent/invalid — drop the stale invite cookie.
3946                clear_invite_cookie(&mut resp);
3947                return resp;
3948            }
3949            Err(err) => {
3950                warn!(%err, did = %session.did, "invite redeem infra error at callback");
3951                return login_error("Login failed while confirming your invite.");
3952            }
3953        }
3954    }
3955
3956    // Mint an opaque, random server-side session id and store the identity under
3957    // it; the cookie carries the (HMAC-signed) sid, never the DID.
3958    let sid = state.sessions.create(Session {
3959        did: session.did.clone(),
3960        handle: session.handle.clone(),
3961    });
3962    let cookie = cookie::sign_session(&sid, &state.config.cookie_secret);
3963    info!(did = %session.did, handle = ?session.handle, "OAuth login OK; session cookie set");
3964
3965    let mut resp = Redirect::to("/").into_response();
3966    set_cookie(&mut resp, &cookie);
3967    clear_binding_cookie(&mut resp);
3968    if clear_invite {
3969        clear_invite_cookie(&mut resp);
3970    }
3971    resp
3972}
3973
3974/// Revoke a DID's OAuth session on BOTH backends, best-effort.
3975///
3976/// Not "whichever backend is live": during a cutover a user's tokens can be in
3977/// either store — they logged in under one backend and are logging out under
3978/// the other. Revoking only the live one would leave a live refresh token
3979/// behind in the other, which is the exact failure sign-out exists to prevent,
3980/// and it would be invisible because the sign-out itself looks successful.
3981///
3982/// Both arms are best-effort. The caller has already decided to sign the user
3983/// out, and a network failure must not trap them in a half-logged-out state.
3984/// How long sign-out will wait for a final read-state flush before revoking
3985/// anyway.
3986///
3987/// Bounded because the flush talks to the user's PDS, and a user trying to leave
3988/// must never be held by a server that is not answering. Three seconds is long
3989/// enough for a healthy `applyWrites` (the production samples run 100–970 ms)
3990/// and short enough that a dead PDS is an inconvenience rather than a trap.
3991const SIGN_OUT_FLUSH_BUDGET: std::time::Duration = std::time::Duration::from_secs(3);
3992
3993/// Flush whatever read-state is still dirty for `did`, then give up quietly.
3994///
3995/// **Called before revoking, because revoking first strands it (#117).**
3996/// `revoke_everywhere` deletes the OAuth session, and a dirty cursor with no
3997/// session cannot be sent by anyone — it parks until the user signs in again,
3998/// which may be never. Flushing first is what stops the common case from
3999/// becoming that.
4000///
4001/// Best-effort by construction: every failure path here falls through to the
4002/// revoke. A flush that times out or errors leaves the cursors dirty, which is
4003/// the parked state the flusher now handles deliberately rather than retrying
4004/// forever.
4005async fn flush_before_revoke(state: &AppState, did: &str) {
4006    match tokio::time::timeout(
4007        SIGN_OUT_FLUSH_BUDGET,
4008        crate::readstate::flush_did(state, did),
4009    )
4010    .await
4011    {
4012        Ok(Ok(())) => {}
4013        Ok(Err(err)) => {
4014            warn!(%did, %err, "sign-out: final read-state flush failed; it will park until next sign-in")
4015        }
4016        Err(_) => warn!(
4017            %did,
4018            budget = ?SIGN_OUT_FLUSH_BUDGET,
4019            "sign-out: final read-state flush timed out; it will park until next sign-in"
4020        ),
4021    }
4022}
4023
4024async fn revoke_everywhere(state: &AppState, did: &str) {
4025    // **Counted under Backend::Sidecar, not left uncounted.** A review found
4026    // that recording only the rust arm let `oauth_revoke` report a clean success
4027    // while every sidecar revocation failed — and for anyone who logged in before
4028    // the cutover, the sidecar store is the ONLY one that held tokens, so the
4029    // rust arm correctly returns NoSession and the metric reads all-clear while
4030    // live refresh tokens sit at the PDS.
4031    //
4032    // Same op name, different backend: the backend column is what distinguishes
4033    // them, so "no revocation failures" means checking both rows, not one.
4034    let sidecar_started = std::time::Instant::now();
4035    let sidecar_ok = match state.sidecar.revoke_session(did).await {
4036        Ok(res) => {
4037            info!(%did, revoked = res.revoked, "sidecar session revoked");
4038            true
4039        }
4040        Err(err) => {
4041            warn!(%did, %err, "sidecar revoke failed; continuing");
4042            false
4043        }
4044    };
4045    state.metrics.record(
4046        crate::metrics::Backend::Sidecar,
4047        "oauth_revoke",
4048        sidecar_started.elapsed().as_micros() as u64,
4049        sidecar_ok,
4050    );
4051
4052    if let Some(runtime) = state.oauth.as_deref() {
4053        let revoke_started = std::time::Instant::now();
4054        let outcome = crate::oauth::revoke::sign_out_discovering(
4055            runtime,
4056            &state.http,
4057            &state.db,
4058            did,
4059            crate::store::now_unix(),
4060        )
4061        .await;
4062        // **Counted, because a warn! nobody reads is not observability.** Until
4063        // this existed, a revocation failure left exactly one trace: a log line.
4064        // "No revocation failures this week" was therefore a statement about
4065        // nobody having looked, which is not the same claim.
4066        //
4067        // NoSession counts as a SUCCESS, deliberately. Logout is idempotent —
4068        // there being nothing to revoke is the correct outcome, not a failure,
4069        // and counting it as an error would make the metric noisy in exactly
4070        // the case that is fine. Only `Failed` means the PDS still holds live
4071        // tokens we asked it to drop.
4072        let revoke_ok = !matches!(outcome, crate::oauth::revoke::Revocation::Failed(_));
4073        state.metrics.record(
4074            crate::metrics::Backend::Rust,
4075            "oauth_revoke",
4076            revoke_started.elapsed().as_micros() as u64,
4077            revoke_ok,
4078        );
4079        match outcome {
4080            crate::oauth::revoke::Revocation::Revoked => {
4081                info!(%did, "rust OAuth session revoked at the PDS")
4082            }
4083            crate::oauth::revoke::Revocation::NoSession => {}
4084            crate::oauth::revoke::Revocation::Failed(reason) => {
4085                warn!(%did, %reason, "rust OAuth revoke failed; the local session is gone regardless")
4086            }
4087        }
4088    }
4089}
4090
4091/// `POST /logout` — end the session everywhere, not just in this browser.
4092///
4093/// Clearing the cookie only stops *this* device from presenting the session;
4094/// the sidecar still holds live OAuth tokens for the DID. So logout now also
4095/// calls the sidecar `POST /internal/revoke {did}`, which revokes the refresh +
4096/// access tokens at the PDS and drops the sidecar's session rows. The local
4097/// registry entry is dropped and the cookie cleared regardless of whether the
4098/// revoke round-trip succeeds (best-effort — a network blip must not trap the
4099/// user in a half-logged-out state).
4100async fn logout(State(state): State<AppState>, headers: HeaderMap) -> Response {
4101    if let Some(user) = current_session(&state, &headers).await {
4102        // Only a real cookie session (`sid` present) has sidecar-held tokens to
4103        // revoke; the dev-DID fallback never handshook the sidecar.
4104        if let Some(sid) = user.sid {
4105            state.sessions.remove(&sid);
4106            // BEFORE the revoke: afterwards there is no session to send it with.
4107            flush_before_revoke(&state, &user.did).await;
4108            revoke_everywhere(&state, &user.did).await;
4109        }
4110    }
4111    let mut resp = Redirect::to("/login").into_response();
4112    set_cookie(
4113        &mut resp,
4114        &format!("{SESSION_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
4115    );
4116    resp
4117}
4118
4119/// Form body for `POST /account/delete` — the confirm-gate. The user must type
4120/// `DELETE` into this field for the purge to run.
4121#[derive(Debug, Deserialize)]
4122struct DeleteAccountForm {
4123    #[serde(default)]
4124    confirm: String,
4125}
4126
4127/// The literal a user must type to confirm the destructive delete.
4128const DELETE_CONFIRM_PHRASE: &str = "DELETE";
4129
4130/// `POST /account/delete` (authed) — the "delete my data" endpoint.
4131///
4132/// Confirm-gated: the form must carry `confirm=DELETE` or we bounce back to
4133/// `/manage` with an explanatory flash and touch nothing. On confirmation it:
4134///   1. purges **every** local row owned by the caller DID (`entry_state`,
4135///      `read_cursor`, `sub_ref`, `beta_access` seat, and any invite codes the
4136///      DID created) via [`store::purge_did_data`], then
4137///   2. calls the sidecar `POST /internal/revoke {did}` so the OAuth tokens are
4138///      revoked at the PDS and the sidecar's session rows are dropped, then
4139///   3. drops the in-memory session and clears the cookie, signing the user out.
4140///
4141/// The subscription/folder/saved *records* in the user's own PDS are
4142/// intentionally left alone — they are the user's data on their own server; the
4143/// `/about` copy and this page's UI both say so, and export stays available.
4144async fn account_delete(
4145    State(state): State<AppState>,
4146    headers: HeaderMap,
4147    Form(form): Form<DeleteAccountForm>,
4148) -> Result<Response, WebError> {
4149    let user = match current_session(&state, &headers).await {
4150        Some(u) => u,
4151        None => return Ok(Redirect::to("/login").into_response()),
4152    };
4153    let did = user.did.clone();
4154
4155    // Confirm-gate: require the exact typed phrase before doing anything.
4156    if form.confirm.trim() != DELETE_CONFIRM_PHRASE {
4157        return Ok(Redirect::to(&format!(
4158            "/manage?flash={}",
4159            qenc("Type DELETE to confirm — nothing was deleted.")
4160        ))
4161        .into_response());
4162    }
4163
4164    // 1. Purge every local row this DID owns (single transaction).
4165    let counts = store::purge_did_data(&state.db, &did).await?;
4166    info!(
4167        %did,
4168        total = counts.total(),
4169        entry_state = counts.entry_state,
4170        read_cursor = counts.read_cursor,
4171        sub_ref = counts.sub_ref,
4172        beta_access = counts.beta_access,
4173        invite_codes = counts.invite_codes,
4174        "account/delete: local rows purged"
4175    );
4176
4177    // 2. Revoke the OAuth session at the sidecar/PDS (best-effort — the local
4178    //    rows are already gone; a network blip must not block the sign-out).
4179    revoke_everywhere(&state, &did).await;
4180
4181    // 3. Drop the in-memory session and clear the cookie: sign the user out.
4182    if let Some(sid) = user.sid {
4183        state.sessions.remove(&sid);
4184    }
4185    let mut resp = Redirect::to(&format!(
4186        "/login?flash={}",
4187        qenc("Your data was deleted and you've been signed out. Thanks for trying FeatherReader.")
4188    ))
4189    .into_response();
4190    set_cookie(
4191        &mut resp,
4192        &format!("{SESSION_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
4193    );
4194    Ok(resp)
4195}
4196
4197/// Re-render the login form with an error banner.
4198fn login_error(msg: &str) -> Response {
4199    render(&LoginTemplate {
4200        repo_url: REPO_URL,
4201        error: msg.to_string(),
4202        flash: String::new(),
4203    })
4204}
4205
4206// ---------------------------------------------------------------------------
4207// Closed-beta invite gate (self-serve redeem + admin mint)
4208// ---------------------------------------------------------------------------
4209
4210/// Form body for `POST /beta/redeem`.
4211#[derive(Debug, Deserialize)]
4212struct RedeemForm {
4213    code: String,
4214}
4215
4216/// `GET /beta/redeem` — render the invite-redeem page. If the seat cap is
4217/// already full we render the "capacity full" variant (no form).
4218async fn beta_redeem_form(State(state): State<AppState>) -> Response {
4219    let full = store::count_beta_access(&state.db)
4220        .await
4221        .map(|n| n >= state.config.beta_cap)
4222        .unwrap_or(false);
4223    render(&BetaRedeemTemplate {
4224        repo_url: REPO_URL,
4225        error: String::new(),
4226        capacity_full: full,
4227    })
4228}
4229
4230/// `POST /beta/redeem` — the **pre-handshake** reservation.
4231///
4232/// Validates the pasted code is *redeemable right now* (exists, active,
4233/// unexpired, and a seat is free) WITHOUT consuming it or binding a DID — the
4234/// visitor has no DID yet. On success it sets a short-lived signed invite cookie
4235/// reserving intent to redeem this code, then sends the visitor to `/login`. The
4236/// OAuth callback later binds the verified DID and atomically consumes the code
4237/// (`store::redeem_code`). This ordering means a non-invited visitor can never
4238/// start OAuth (and burn a sidecar handshake).
4239async fn beta_redeem_submit(
4240    State(state): State<AppState>,
4241    Form(form): Form<RedeemForm>,
4242) -> Response {
4243    let code = form.code.trim().to_uppercase();
4244    if code.is_empty() {
4245        return render(&BetaRedeemTemplate {
4246            repo_url: REPO_URL,
4247            error: "Enter your invite code.".to_string(),
4248            capacity_full: false,
4249        });
4250    }
4251
4252    match preflight_code(&state, &code).await {
4253        Ok(()) => {
4254            let cookie = sign_invite(&code, &state.config.cookie_secret);
4255            let mut resp = Redirect::to("/login").into_response();
4256            set_cookie(&mut resp, &cookie);
4257            info!("invite code preflight OK; reserving intent + redirecting to /login");
4258            resp
4259        }
4260        Err(policy) => {
4261            warn!(?policy, "invite code preflight rejected");
4262            redeem_bounce(&policy)
4263        }
4264    }
4265}
4266
4267/// Read-only preflight of an invite code for the pre-handshake reservation:
4268/// verify it exists, is active, is not past `expires_at`, and that a seat is
4269/// free — mirroring the checks `store::redeem_code` will re-run atomically at
4270/// callback time. Does NOT consume the code or grant a seat. Returns the same
4271/// typed [`store::RedeemError`] variants so the two paths share one message map.
4272async fn preflight_code(state: &AppState, code: &str) -> Result<(), store::RedeemError> {
4273    // Cap check first: a clear "capacity full" beats "code invalid" when both.
4274    // FAIL CLOSED on a count error — an `unwrap_or(0)` would let a DB blip read as
4275    // "0 seats used" and wave the redeem through the preflight. (`redeem_code`
4276    // still backstops the real cap inside its tx, so this is a consistency /
4277    // defence-in-depth fix, not the only guard.) Treat an unverifiable count as
4278    // capacity-full: the redeemer sees "at capacity, try later" rather than a mint
4279    // that might overrun the cap.
4280    let count = match store::count_beta_access(&state.db).await {
4281        Ok(n) => n,
4282        Err(err) => {
4283            warn!(%err, "preflight_code: count_beta_access failed; failing closed");
4284            return Err(store::RedeemError::CapacityFull);
4285        }
4286    };
4287    if count >= state.config.beta_cap {
4288        return Err(store::RedeemError::CapacityFull);
4289    }
4290    // Look up the code's current status + expiry (read-only).
4291    let row = sqlx::query_as::<_, (String, i64)>(
4292        "SELECT status, expires_at FROM invite_codes WHERE code = ?1",
4293    )
4294    .bind(code)
4295    .fetch_optional(&state.db)
4296    .await
4297    .ok()
4298    .flatten();
4299    let (status, expires_at) = match row {
4300        Some(r) => r,
4301        None => return Err(store::RedeemError::NotFound),
4302    };
4303    let now = chrono::Utc::now().timestamp();
4304    match status.as_str() {
4305        "active" if expires_at >= now => Ok(()),
4306        "active" => Err(store::RedeemError::Expired),
4307        "expired" => Err(store::RedeemError::Expired),
4308        // "redeemed" or anything else non-active.
4309        _ => Err(store::RedeemError::AlreadyRedeemed),
4310    }
4311}
4312
4313/// Map a [`store::RedeemError`] to the invite page with the right message. Used
4314/// by both the preflight (`POST /beta/redeem`) and the callback bind path.
4315fn redeem_bounce(policy: &store::RedeemError) -> Response {
4316    use store::RedeemError::*;
4317    let (msg, capacity_full) = match policy {
4318        NotFound => ("That invite code isn't valid.", false),
4319        Expired => ("That invite code has expired.", false),
4320        AlreadyRedeemed => ("That invite code has already been used.", false),
4321        CapacityFull => ("", true),
4322    };
4323    render(&BetaRedeemTemplate {
4324        repo_url: REPO_URL,
4325        error: msg.to_string(),
4326        capacity_full,
4327    })
4328}
4329
4330/// Query for `POST /admin/invites` — how many codes to mint (`?n=`, default 1).
4331#[derive(Debug, Deserialize, Default)]
4332struct MintQuery {
4333    #[serde(default)]
4334    n: Option<u32>,
4335}
4336
4337/// `POST /admin/invites?n=N` — mint N invite codes.
4338///
4339/// `GET /oauth/client-metadata.json` — the client's published identity.
4340///
4341/// **This URL IS the `client_id`.** The PDS fetches it during every login and
4342/// caches it against every existing grant, so it must keep answering at exactly
4343/// this path across the cutover — the sidecar serves the same document at the
4344/// same URL today, proxied by the edge.
4345///
4346/// Served whatever backend is live: a request that arrives here is from a PDS
4347/// resolving our identity, and it has no idea which of our two implementations
4348/// is currently answering repo calls.
4349async fn oauth_client_metadata(State(state): State<AppState>) -> Response {
4350    let Some(runtime) = state.oauth.as_deref() else {
4351        // The sidecar is serving this path in front of us, or nothing is.
4352        return (StatusCode::NOT_FOUND, "no client metadata\n").into_response();
4353    };
4354    axum::Json(crate::oauth::metadata::client_metadata(&runtime.client)).into_response()
4355}
4356
4357/// `GET /oauth/jwks.json` — the client's public signing key.
4358///
4359/// Production only. The localhost dev client is a PUBLIC client: it registers no
4360/// key and signs no assertions, so publishing a JWKS there would advertise a
4361/// credential that is never used — and would make a dev deployment look like a
4362/// confidential client to anyone reading it.
4363async fn oauth_jwks(State(state): State<AppState>) -> Response {
4364    let Some(runtime) = state.oauth.as_deref() else {
4365        return (StatusCode::NOT_FOUND, "no jwks\n").into_response();
4366    };
4367    match runtime.client_key.as_ref() {
4368        Some(key) => match key.jwks_document() {
4369            Ok(doc) => axum::Json(doc).into_response(),
4370            Err(err) => {
4371                warn!(%err, "could not render the client JWKS");
4372                (StatusCode::INTERNAL_SERVER_ERROR, "jwks unavailable\n").into_response()
4373            }
4374        },
4375        None => (StatusCode::NOT_FOUND, "this client publishes no jwks\n").into_response(),
4376    }
4377}
4378
4379/// How many failing feeds `/admin/metrics` will name. One response, so bounded.
4380const ADMIN_FAILING_FEED_LIMIT: i64 = 200;
4381
4382/// `GET /admin/metrics` — repo-op latency for both backends, as plain text.
4383///
4384/// Admin-gated on the same rule as the invite minter: the table names every
4385/// operation the reader performs and how often each fails, which is an
4386/// operational picture rather than public information.
4387///
4388/// Text, not JSON or HTML: it is read by a person deciding whether the cutover
4389/// is safe, and the comparison is two rows side by side.
4390async fn admin_metrics(State(state): State<AppState>, headers: HeaderMap) -> Response {
4391    let did = match current_did(&state, &headers).await {
4392        Some(d) => d,
4393        None => return (StatusCode::UNAUTHORIZED, "sign in first\n").into_response(),
4394    };
4395    if !state.config.admin_seed_dids().iter().any(|d| d == &did) {
4396        warn!(%did, "admin metrics denied: not an admin-seed DID");
4397        return (StatusCode::FORBIDDEN, "not an admin\n").into_response();
4398    }
4399
4400    // Flush first, so the table includes this process's traffic up to now.
4401    // Then read the PERSISTED rows, which is the only place both backends can
4402    // appear at once -- a flip is a restart, and in-process memory only ever
4403    // holds the backend currently running.
4404    if let Err(err) =
4405        crate::metrics::flush(&state.metrics, &state.db, crate::store::now_unix()).await
4406    {
4407        warn!(%err, "could not flush repo timings before rendering");
4408    }
4409    let rows = match crate::metrics::persisted_rows(&state.db).await {
4410        Ok(rows) => rows,
4411        Err(err) => {
4412            warn!(%err, "could not read persisted repo timings");
4413            return (StatusCode::INTERNAL_SERVER_ERROR, "metrics unavailable\n").into_response();
4414        }
4415    };
4416
4417    // The live backend is named at the top: a table of two populated rows is
4418    // ambiguous about which one is currently serving users.
4419    // Parked read-state, alongside the timings. The flusher no longer logs
4420    // these every round (#117), so without a number here the state would be
4421    // silent — which is the failure the noisy loop at least did not have.
4422    let parked = match crate::store::parked_readstate_dids(&state.db).await {
4423        Ok(n) => n.to_string(),
4424        Err(err) => {
4425            warn!(%err, "could not count parked read-state DIDs");
4426            "unknown".to_string()
4427        }
4428    };
4429    // **The half the public histogram cannot carry.** `/stats` reports counts by
4430    // cause and nothing else, deliberately — but `fetch` covers DNS failure,
4431    // timeout, SSRF refusal AND this reader's own bugs, so the count alone
4432    // cannot separate "the publishers are gone" from "we are broken". #159 was
4433    // the latter and took a production investigation to establish. Named feeds
4434    // and their error text belong here, behind ALLOWED_DIDS.
4435    let failing = match crate::store::failing_feeds(&state.db, ADMIN_FAILING_FEED_LIMIT).await {
4436        Ok(f) => f,
4437        Err(err) => {
4438            warn!(%err, "could not list failing feeds");
4439            Vec::new()
4440        }
4441    };
4442    let mut failing_block = String::new();
4443    if !failing.is_empty() {
4444        failing_block.push_str("\nfailing feeds (worst first)\n");
4445        for f in &failing {
4446            failing_block.push_str(&format!(
4447                "  {:>4}x  {:<8}  {}\n          {}\n",
4448                f.consecutive_errors,
4449                f.kind.as_deref().unwrap_or("unknown"),
4450                f.url,
4451                f.detail.as_deref().unwrap_or("(no detail recorded)"),
4452            ));
4453        }
4454    }
4455
4456    // **Capacity that no other page can show.** The global ceiling counts every
4457    // row (`store::count_feeds`), but `/stats` measures the poller and excludes
4458    // unpollable ones — so an instance can be at its cap with every public
4459    // number saying otherwise. A review found exactly that gap.
4460    let unpollable = match crate::store::unpollable_feeds(&state.db).await {
4461        Ok(n) => n,
4462        Err(err) => {
4463            warn!(%err, "could not count unpollable feeds");
4464            -1
4465        }
4466    };
4467    let cached = crate::store::count_feeds(&state.db).await.unwrap_or(-1);
4468
4469    let body = format!(
4470        "live backend: {}\nparked read-state DIDs: {}\n\
4471         feeds cached: {} (ceiling {}), of which unpollable: {}\n\n{}{}",
4472        state.config.repo_backend.as_str(),
4473        parked,
4474        cached,
4475        state.config.max_feeds_global,
4476        unpollable,
4477        crate::metrics::render(&rows),
4478        failing_block,
4479    );
4480    (StatusCode::OK, body).into_response()
4481}
4482
4483/// Authorized ONLY for a live session whose DID is in the `ALLOWED_DIDS` admin
4484/// seed (`config.admin_seed_dids`). Returns the freshly-minted codes as
4485/// newline-separated `text/plain`. Deliberately minimal (no HTML UI).
4486async fn admin_mint_invites(
4487    State(state): State<AppState>,
4488    headers: HeaderMap,
4489    Query(q): Query<MintQuery>,
4490) -> Response {
4491    // Require a real, current session (not just a DID string) whose DID is an
4492    // admin-seed DID. `current_did` already re-checks the beta gate.
4493    let did = match current_did(&state, &headers).await {
4494        Some(d) => d,
4495        None => return (StatusCode::UNAUTHORIZED, "sign in first\n").into_response(),
4496    };
4497    if !state.config.admin_seed_dids().iter().any(|d| d == &did) {
4498        warn!(%did, "admin mint denied: not an admin-seed DID");
4499        return (StatusCode::FORBIDDEN, "not an admin\n").into_response();
4500    }
4501
4502    let n = q.n.unwrap_or(1).clamp(1, 100);
4503    let mut codes = Vec::with_capacity(n as usize);
4504    for _ in 0..n {
4505        match store::mint_code(&state.db, &did, INVITE_TTL_SECS).await {
4506            Ok(code) => codes.push(code),
4507            Err(err) => {
4508                warn!(%err, %did, "admin mint_code failed");
4509                return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
4510            }
4511        }
4512    }
4513    info!(%did, count = codes.len(), "admin minted invite codes");
4514    let mut body = codes.join("\n");
4515    body.push('\n');
4516    (StatusCode::OK, body).into_response()
4517}
4518
4519// ---------------------------------------------------------------------------
4520// Bot claim link: /claim?t=<token>  +  POST /bot/claims (shared-secret mint)
4521// ---------------------------------------------------------------------------
4522
4523/// Query for `GET /claim`.
4524#[derive(Debug, Deserialize)]
4525struct ClaimQuery {
4526    /// The opaque claim token from the bot's public follow-back skeet.
4527    t: Option<String>,
4528}
4529
4530/// `GET /claim?t=<token>` — redeem a bot-issued claim link.
4531///
4532/// The follow→invite bot posts a public skeet mentioning a new follower with a
4533/// link here. The token wraps a pre-minted invite code (never the raw code — see
4534/// [`sign_claim_token`]). On a valid, still-redeemable token this behaves exactly
4535/// like a successful `POST /beta/redeem`: it sets the reserving `fr_invite`
4536/// cookie and sends the visitor to `/login`, so they flow through OAuth and the
4537/// callback atomically consumes the code (`store::redeem_code`) — the same
4538/// machinery as a pasted code. On any failure it bounces to the invite page with
4539/// the matching message.
4540///
4541/// Single-use / grabbability: a token in a public URL is grabbable. The code it
4542/// wraps is single-use (redeem flips `active→redeemed`), and `preflight_code`
4543/// here rejects an already-used / expired / capacity-full code before reserving,
4544/// so a replayed link past the first successful claim is refused. The residual
4545/// window is the same as any pasted invite code: whoever completes OAuth *first*
4546/// with a live reservation wins the seat. The per-IP rate limit on `/claim`
4547/// blunts brute-force enumeration.
4548async fn claim(State(state): State<AppState>, Query(q): Query<ClaimQuery>) -> Response {
4549    let token = match q.t {
4550        Some(t) if !t.is_empty() => t,
4551        _ => {
4552            warn!("claim link with no token");
4553            return redeem_bounce(&store::RedeemError::NotFound);
4554        }
4555    };
4556
4557    // Unwrap the token → the invite code it reserves. A tampered/forged token
4558    // yields nothing → treat as an invalid code (don't leak whether it parsed).
4559    let code = match claim_token_code(&token, &state.config.cookie_secret) {
4560        Some(c) => c,
4561        None => {
4562            warn!("claim token invalid (bad signature / malformed)");
4563            return redeem_bounce(&store::RedeemError::NotFound);
4564        }
4565    };
4566
4567    // Re-run the same preflight as the pasted-code path: exists, active,
4568    // unexpired, seat free. This is what makes a replayed link past first-claim
4569    // (or past cap) fail cleanly.
4570    match preflight_code(&state, &code).await {
4571        Ok(()) => {
4572            let cookie = sign_invite(&code, &state.config.cookie_secret);
4573            let mut resp = Redirect::to("/login").into_response();
4574            set_cookie(&mut resp, &cookie);
4575            info!("claim token preflight OK; reserving intent + redirecting to /login");
4576            resp
4577        }
4578        Err(policy) => {
4579            warn!(?policy, "claim token preflight rejected");
4580            redeem_bounce(&policy)
4581        }
4582    }
4583}
4584
4585/// The JSON body `POST /bot/claims` accepts — the follower the claim is FOR.
4586///
4587/// Passing the follower DID makes the APP the authoritative deduper: the app can
4588/// short-circuit a DID that already holds a seat, and return the SAME code for a
4589/// DID that already has an outstanding claim — so a bot-host state loss cannot
4590/// re-mint or re-post per follower. Handle is advisory (logs only).
4591#[derive(Debug, Default, Deserialize)]
4592struct BotClaimRequest {
4593    /// The follower's DID (the idempotency key). Optional for backward-compat: an
4594    /// omitted DID falls back to the old un-keyed mint (no server-side dedupe).
4595    #[serde(default)]
4596    did: Option<String>,
4597    /// The follower's handle (advisory; recorded for operator logs only).
4598    #[serde(default)]
4599    #[allow(dead_code)]
4600    handle: Option<String>,
4601}
4602
4603/// The JSON body `POST /bot/claims` returns on success.
4604#[derive(Debug, serde::Serialize)]
4605struct BotClaimResponse {
4606    /// Server-side dedupe outcome, so the bot knows whether to post:
4607    /// `"minted"` (a fresh code — post the claim link), `"existing"` (this DID
4608    /// already had an outstanding claim; the SAME code/token/url is returned, so an
4609    /// idempotent re-post is safe), or `"already_seated"` (this DID already holds
4610    /// beta access; code/token/url are empty and the bot should post NOTHING).
4611    status: &'static str,
4612    /// The bare invite code (`FEATHER-…`) — for the bot's own logs/idempotency
4613    /// store. NEVER post this publicly; post the `url` instead. Empty when
4614    /// `already_seated`.
4615    code: String,
4616    /// The opaque claim token (the code wrapped + signed). Empty when
4617    /// `already_seated`.
4618    token: String,
4619    /// The full claim URL to put in the public skeet: `${public_url}/claim?t=…`.
4620    /// Empty when `already_seated`.
4621    url: String,
4622}
4623
4624/// `POST /bot/claims` — headless, shared-secret mint of a claim link.
4625///
4626/// Auth is a bearer shared secret in the `X-Bot-Secret` header (== the Fly secret
4627/// `FEATHERREADER_BOT_SECRET`), NOT an OAuth cookie — so the homelab-hosted bot
4628/// can call it. When `FEATHERREADER_BOT_SECRET` is unset the endpoint is DISABLED
4629/// (503), so a bare/dev instance never exposes an unauthenticated mint.
4630///
4631/// Server-side DID idempotency (the authoritative dedupe backstop): the request
4632/// body carries the follower `did`. The app — not the bot's local SQLite — is the
4633/// source of truth, so a bot-host state loss cannot re-mint or re-post per
4634/// follower:
4635///   * DID already holds beta access → `200 {status:"already_seated"}` (empty
4636///     code/url; the bot marks it handled and posts NOTHING);
4637///   * DID already has an outstanding active claim → `200 {status:"existing"}`
4638///     returning the SAME code/token/url (idempotent — never a second mint);
4639///   * otherwise mint a fresh code recorded FOR that DID → `200 {status:"minted"}`.
4640///
4641/// Cap accounting: the bot must not promise more claims than seats remain, so
4642/// this refuses with `409 Conflict {"error":"full"}` when
4643/// `beta_access + outstanding active codes >= FEATHERREADER_BETA_CAP`. (The
4644/// redeem-time cap in `store::redeem_code` is still the hard backstop.) The count
4645/// queries FAIL CLOSED: a DB error propagates as `500` rather than reading 0 and
4646/// minting past the cap.
4647///
4648/// On a fresh mint it uses the generous claim TTL (`FEATHERREADER_CLAIM_TTL_SECS`,
4649/// default 14d — the admin browser flow's 30-min TTL would expire before the
4650/// follower taps an async-delivered link).
4651async fn bot_mint_claim(
4652    State(state): State<AppState>,
4653    headers: HeaderMap,
4654    body: axum::body::Bytes,
4655) -> Response {
4656    // 1. The endpoint is OFF unless a bot secret is configured.
4657    let bot_secret = match state.config.bot_secret.as_deref() {
4658        Some(s) => s,
4659        None => {
4660            warn!(
4661                "POST /bot/claims called but FEATHERREADER_BOT_SECRET is unset (endpoint disabled)"
4662            );
4663            return (
4664                StatusCode::SERVICE_UNAVAILABLE,
4665                "bot mint endpoint disabled (FEATHERREADER_BOT_SECRET unset)\n",
4666            )
4667                .into_response();
4668        }
4669    };
4670
4671    // 2. Constant-time bearer check on the X-Bot-Secret header.
4672    let presented = headers
4673        .get("x-bot-secret")
4674        .and_then(|v| v.to_str().ok())
4675        .unwrap_or("");
4676    if !bot_secret_matches(presented, bot_secret) {
4677        warn!("POST /bot/claims rejected: bad or missing X-Bot-Secret");
4678        return (StatusCode::UNAUTHORIZED, "bad bot secret\n").into_response();
4679    }
4680
4681    // 2b. Parse the (optional) JSON body → the follower DID/handle. An empty body
4682    // (legacy caller) parses to an all-None request; a malformed body is a 400.
4683    let req: BotClaimRequest = if body.is_empty() {
4684        BotClaimRequest::default()
4685    } else {
4686        match serde_json::from_slice(&body) {
4687            Ok(r) => r,
4688            Err(err) => {
4689                warn!(%err, "POST /bot/claims: bad JSON body");
4690                return (StatusCode::BAD_REQUEST, "bad json body\n").into_response();
4691            }
4692        }
4693    };
4694    let follower_did = req.did.as_deref().filter(|d| !d.is_empty());
4695
4696    // 3. Server-side DID idempotency (only when a DID was supplied):
4697    if let Some(did) = follower_did {
4698        // 3a. Already seated → tell the bot to post nothing.
4699        match store::has_beta_access(&state.db, did).await {
4700            Ok(true) => {
4701                info!("bot mint: DID already holds beta access; already_seated");
4702                return bot_claim_json(BotClaimResponse {
4703                    status: "already_seated",
4704                    code: String::new(),
4705                    token: String::new(),
4706                    url: String::new(),
4707                });
4708            }
4709            Ok(false) => {}
4710            Err(err) => {
4711                // Fail closed: a DB error must not fall through to a fresh mint.
4712                warn!(%err, "bot mint: has_beta_access failed");
4713                return (StatusCode::INTERNAL_SERVER_ERROR, "lookup failed\n").into_response();
4714            }
4715        }
4716        // 3b. Outstanding active claim for this DID → return the SAME code (no
4717        // second mint). This is what survives a bot-host state loss.
4718        match store::find_active_code_for_did(&state.db, did).await {
4719            Ok(Some(code)) => {
4720                info!("bot mint: existing outstanding claim for DID; returning same code");
4721                let token = sign_claim_token(&code, &state.config.cookie_secret);
4722                let url = format!("{}/claim?t={}", state.config.public_url, qenc(&token));
4723                return bot_claim_json(BotClaimResponse {
4724                    status: "existing",
4725                    code,
4726                    token,
4727                    url,
4728                });
4729            }
4730            Ok(None) => {}
4731            Err(err) => {
4732                warn!(%err, "bot mint: find_active_code_for_did failed");
4733                return (StatusCode::INTERNAL_SERVER_ERROR, "lookup failed\n").into_response();
4734            }
4735        }
4736    }
4737
4738    // 4. Cap accounting: seats already granted + outstanding unredeemed codes.
4739    //    FAIL CLOSED — a count error is a 500, not a silent mint past the cap.
4740    let granted = match store::count_beta_access(&state.db).await {
4741        Ok(n) => n,
4742        Err(err) => {
4743            warn!(%err, "bot mint: count_beta_access failed; failing closed");
4744            return (StatusCode::INTERNAL_SERVER_ERROR, "count failed\n").into_response();
4745        }
4746    };
4747    let outstanding = match store::count_active_codes(&state.db).await {
4748        Ok(n) => n,
4749        Err(err) => {
4750            warn!(%err, "bot mint: count_active_codes failed; failing closed");
4751            return (StatusCode::INTERNAL_SERVER_ERROR, "count failed\n").into_response();
4752        }
4753    };
4754    if granted + outstanding >= state.config.beta_cap {
4755        info!(
4756            granted,
4757            outstanding,
4758            cap = state.config.beta_cap,
4759            "bot mint refused: at capacity"
4760        );
4761        return (
4762            StatusCode::CONFLICT,
4763            [(header::CONTENT_TYPE, "application/json")],
4764            "{\"error\":\"full\"}\n",
4765        )
4766            .into_response();
4767    }
4768
4769    // 5. Mint with the generous claim TTL, recording the follower DID (when given)
4770    //    so a re-request for the same DID returns THIS code idempotently.
4771    let bot_did = state
4772        .config
4773        .admin_seed_dids()
4774        .first()
4775        .cloned()
4776        .unwrap_or_else(|| "did:bot:featherreader".to_string());
4777    let minted = match follower_did {
4778        Some(did) => {
4779            store::mint_code_for_did(&state.db, &bot_did, state.config.claim_ttl_secs, did).await
4780        }
4781        None => store::mint_code(&state.db, &bot_did, state.config.claim_ttl_secs).await,
4782    };
4783    let code = match minted {
4784        Ok(c) => c,
4785        // S4: the dedupe check (3b) and this mint are separate statements, so two
4786        // concurrent requests for one DID can both fall through 3b's `Ok(None)`.
4787        // The partial unique index `idx_invite_codes_intended_active` makes the
4788        // loser's INSERT fail (only one active row per intended DID), which
4789        // surfaces here as a conflict. Recover by returning the winner's existing
4790        // code (same shape as the 3b idempotent path) instead of a 500.
4791        Err(err) if follower_did.is_some() && store::is_intended_active_conflict(&err) => {
4792            match store::find_active_code_for_did(&state.db, follower_did.unwrap()).await {
4793                Ok(Some(code)) => {
4794                    info!("bot mint: lost the mint race; returning the concurrently-minted code");
4795                    let token = sign_claim_token(&code, &state.config.cookie_secret);
4796                    let url = format!("{}/claim?t={}", state.config.public_url, qenc(&token));
4797                    return bot_claim_json(BotClaimResponse {
4798                        status: "existing",
4799                        code,
4800                        token,
4801                        url,
4802                    });
4803                }
4804                // The winner's row vanished between the conflict and this lookup
4805                // (redeemed/expired/purged in the gap) — nothing to hand back.
4806                // Fail closed rather than silently mint past the just-hit guard.
4807                Ok(None) => {
4808                    warn!("bot mint: conflict but no active code found on recovery");
4809                    return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
4810                }
4811                Err(err) => {
4812                    warn!(%err, "bot mint: recovery lookup after conflict failed");
4813                    return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
4814                }
4815            }
4816        }
4817        Err(err) => {
4818            warn!(%err, "bot mint_code failed");
4819            return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
4820        }
4821    };
4822    let token = sign_claim_token(&code, &state.config.cookie_secret);
4823    let url = format!("{}/claim?t={}", state.config.public_url, qenc(&token));
4824    info!("bot minted a claim code + token");
4825
4826    bot_claim_json(BotClaimResponse {
4827        status: "minted",
4828        code,
4829        token,
4830        url,
4831    })
4832}
4833
4834/// Serialize a [`BotClaimResponse`] to a `200 application/json` response (or a
4835/// `500` if serialization somehow fails).
4836fn bot_claim_json(resp: BotClaimResponse) -> Response {
4837    match serde_json::to_string(&resp) {
4838        Ok(body) => (
4839            StatusCode::OK,
4840            [(header::CONTENT_TYPE, "application/json")],
4841            body,
4842        )
4843            .into_response(),
4844        Err(err) => {
4845            warn!(%err, "serializing bot claim response failed");
4846            (StatusCode::INTERNAL_SERVER_ERROR, "serialize failed\n").into_response()
4847        }
4848    }
4849}
4850
4851/// Constant-time equality for the bot bearer secret (avoid a timing side-channel
4852/// on the shared secret). Delegates to the same `cookie::constant_time_eq` used
4853/// by the HMAC checks so there is one comparator to audit; a length mismatch
4854/// short-circuits to `false`, which is fine — the secret length isn't sensitive.
4855fn bot_secret_matches(presented: &str, expected: &str) -> bool {
4856    cookie::constant_time_eq(presented.as_bytes(), expected.as_bytes())
4857}
4858
4859// ---------------------------------------------------------------------------
4860// Signed, short-lived invite cookie (reuses the session-cookie HMAC helper)
4861// ---------------------------------------------------------------------------
4862
4863/// Sign the reserved invite `code` into a short-lived `Set-Cookie` value. Reuses
4864/// the same HMAC-SHA256 helper as the session cookie; the payload is the code
4865/// itself (base64url) rather than an opaque sid, since the code IS the reserved
4866/// intent the callback consumes.
4867fn sign_invite(code: &str, secret: &str) -> String {
4868    cookie::sign_value(INVITE_COOKIE, code, secret, INVITE_TTL_SECS)
4869}
4870
4871/// Verify + read the reserved invite code out of the request's invite cookie
4872/// (`None` if absent, tampered, or forged). No expiry is enforced here beyond
4873/// the cookie's own `Max-Age`; the atomic `redeem_code` at the callback is the
4874/// authority on the code's live status.
4875fn invite_cookie_code(headers: &HeaderMap, secret: &str) -> Option<String> {
4876    cookie::verify_value(headers, INVITE_COOKIE, secret)
4877}
4878
4879/// Domain-separation label for the claim TOKEN's HMAC (distinct from the
4880/// `fr_invite`/`fr_session` cookie names), so a token can never be replayed as a
4881/// cookie value and vice-versa.
4882const CLAIM_TOKEN_LABEL: &str = "claim-token";
4883
4884/// Sign an invite `code` into a URL-safe claim TOKEN: `b64url(code).<sig>`
4885/// (HMAC-SHA256 over `"claim-token" || 0x00 || code`).
4886///
4887/// NOTE — the token is NOT confidential: the `b64url(code)` half is trivially
4888/// decodable by anyone, so the raw `FEATHER-…` code is effectively public in the
4889/// claim URL. The token's security is INTEGRITY + SINGLE-USE, not secrecy: the
4890/// `<sig>` HMAC means only this instance can MINT a valid token (a forged/guessed
4891/// code won't verify), the wrapped code is single-use (redeem flips
4892/// `active→redeemed`), and `/claim` is per-IP rate-limited. Wrapping keeps the
4893/// token one self-contained string needing no server-side token table; it does
4894/// NOT hide the code.
4895fn sign_claim_token(code: &str, secret: &str) -> String {
4896    cookie::sign_token(CLAIM_TOKEN_LABEL, code, secret)
4897}
4898
4899/// Verify a claim token and return the invite code it wraps (`None` on a tampered
4900/// / forged / malformed token). The code's live status (active/unexpired/seat
4901/// free) is re-checked by `preflight_code`; this only proves the token was minted
4902/// by this instance.
4903fn claim_token_code(token: &str, secret: &str) -> Option<String> {
4904    cookie::verify_token(CLAIM_TOKEN_LABEL, token, secret)
4905}
4906
4907/// Clear the invite cookie on a response (after a successful bind, or when the
4908/// reservation turned out to be stale).
4909fn clear_invite_cookie(resp: &mut Response) {
4910    set_cookie(
4911        resp,
4912        &format!("{INVITE_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
4913    );
4914}
4915
4916// ---------------------------------------------------------------------------
4917// OPML import + export
4918// ---------------------------------------------------------------------------
4919
4920/// `POST /opml` — import subscriptions from an OPML document.
4921///
4922/// Accepts either a multipart file upload (field `file`) or a pasted textarea
4923/// (field `opml`). The parsed feeds each become a `community.lexicon.rss.folder`
4924/// (for any named folders) + a `community.lexicon.rss.subscription` record in the
4925/// user's PDS via the records layer's bulk-add (`add_subscriptions_bulk`, one
4926/// `applyWrites` round-trip). Feeds are also upserted into the local cache so
4927/// they show immediately; polling is left to the background poller.
4928async fn import_opml(
4929    State(state): State<AppState>,
4930    headers: HeaderMap,
4931    mut multipart: Multipart,
4932) -> Result<Response, WebError> {
4933    let did = match current_did(&state, &headers).await {
4934        Some(d) => d,
4935        None => return Ok(Redirect::to("/login").into_response()),
4936    };
4937    let pool = &state.db;
4938
4939    // Collect the OPML text from whichever field carried it. Multipart errors
4940    // are mapped to their axum-native response so that an over-cap upload (the
4941    // `DefaultBodyLimit` on this route, see `OPML_BODY_LIMIT`) surfaces as
4942    // `413 Payload Too Large` rather than being swallowed by the blanket
4943    // `WebError` → `500` conversion.
4944    let mut opml_text = String::new();
4945    while let Some(field) = multipart.next_field().await.map_err(multipart_response)? {
4946        let name = field.name().unwrap_or("").to_string();
4947        if name == "opml" || name == "file" {
4948            let bytes = field.bytes().await.map_err(multipart_response)?;
4949            if !bytes.is_empty() {
4950                opml_text = String::from_utf8_lossy(&bytes).into_owned();
4951                if name == "file" {
4952                    break;
4953                }
4954            }
4955        }
4956    }
4957
4958    // A parse FAILURE and an empty-but-valid file are different things, and
4959    // `unwrap_or_default` collapsed them: a malformed export was reported to the
4960    // reader as "No feeds found in that OPML", which sends them looking at their
4961    // old reader for feeds that are right there in the file.
4962    let feeds =
4963        match opml::parse_opml(&opml_text) {
4964            Ok(feeds) => feeds,
4965            Err(err) => {
4966                warn!(%err, %did, "OPML import could not parse the uploaded file");
4967                return Ok(Redirect::to(&format!(
4968                "/?flash={}",
4969                qenc("That file could not be read as OPML. Export it again from your other reader?")
4970            ))
4971                .into_response());
4972            }
4973        };
4974    if feeds.is_empty() {
4975        info!(%did, "OPML import found no feeds");
4976        return Ok(
4977            Redirect::to(&format!("/?flash={}", qenc("No feeds found in that OPML")))
4978                .into_response(),
4979        );
4980    }
4981
4982    // Create any named folders first, mapping folder name → at:// URI so
4983    // subscriptions can reference them.
4984    let now = now_rfc3339();
4985    let mut folder_uris: std::collections::HashMap<String, String> =
4986        std::collections::HashMap::new();
4987    // Reuse existing folders where the name already exists.
4988    if let Ok(existing) = state.repo().list_folders_sorted(&did).await {
4989        for (rkey, folder) in existing {
4990            folder_uris
4991                .entry(folder.name.clone())
4992                .or_insert_with(|| folder_uri(&did, &rkey));
4993        }
4994    }
4995    let mut wanted_folders: Vec<String> = feeds
4996        .iter()
4997        .filter_map(|f| f.folder.clone())
4998        .filter(|n| !n.is_empty())
4999        .collect();
5000    wanted_folders.sort();
5001    wanted_folders.dedup();
5002    for name in wanted_folders {
5003        if folder_uris.contains_key(&name) {
5004            continue;
5005        }
5006        let folder = Folder::new(name.clone(), now.clone());
5007        match state.repo().add_folder(&did, &folder).await {
5008            Ok(rkey) => {
5009                folder_uris.insert(name, folder_uri(&did, &rkey));
5010            }
5011            Err(err) => warn!(%err, %did, "OPML folder create failed"),
5012        }
5013    }
5014
5015    // Build one subscription record per PUBLIC feed + upsert the local cache row.
5016    // Private/paid feeds are SKIPPED entirely (never stored, fetched, or written)
5017    // and reported back to the user — the same public-feeds-only stance as the
5018    // single-add path, so an OPML import can't leak a Substack/Patreon/podcast
5019    // token onto the public network either.
5020    // Per-DID subscription cap: an OPML import must not blow past the cap. Compute
5021    // the remaining headroom (cap − existing) once; public feeds beyond it are
5022    // TRIMMED (not imported) and reported. `<= 0` disables the cap.
5023    let sub_cap = state.config.max_subs_per_did;
5024    let mut headroom: Option<i64> = if sub_cap > 0 {
5025        let existing = store::count_subscriptions_for_did(pool, &did)
5026            .await
5027            .unwrap_or(0);
5028        Some((sub_cap - existing).max(0))
5029    } else {
5030        None
5031    };
5032    let mut trimmed_over_cap: usize = 0;
5033
5034    // Global feeds ceiling: an OPML import must not blow past the shared cache
5035    // ceiling any more than the single-add path may. Seed the remaining global
5036    // headroom (cap − current feeds) once, and only a BRAND-NEW feed URL (one
5037    // not already cached) consumes it. Existing/duplicate URLs add no row and
5038    // are always allowed. Feeds past the ceiling are TRIMMED and reported.
5039    // `<= 0` disables the ceiling.
5040    let feeds_cap = state.config.max_feeds_global;
5041    let mut global_headroom: Option<i64> = if feeds_cap > 0 {
5042        let existing = store::count_feeds(pool).await.unwrap_or(0);
5043        Some((feeds_cap - existing).max(0))
5044    } else {
5045        None
5046    };
5047    let mut trimmed_over_global: usize = 0;
5048
5049    let mut subs = Vec::with_capacity(feeds.len());
5050    let mut skipped_private: Vec<String> = Vec::new();
5051    // Imported into the PDS but not cached locally, so not pollable until the
5052    // next import touches them. Counted rather than only logged — see below.
5053    let mut uncached: usize = 0;
5054    // Entries this instance cannot store at all (an `at://` publication with
5055    // the flag off, an unsupported scheme). Counted, because the `continue`
5056    // below used to increment nothing while the privacy branch beside it
5057    // produced a label — so an OPML from a standard.site-enabled instance
5058    // imported "successfully" with entries missing and no reason given.
5059    let mut skipped_unsupported: usize = 0;
5060    for f in &feeds {
5061        // `xmlUrl` is whatever the uploaded file says, and nothing on this path
5062        // ever parsed it — the single-add path can't reach here because
5063        // `resolve_feed_url` must parse AND successfully fetch first. So
5064        // `javascript:alert(1)` and `file:///etc/passwd` were both accepted,
5065        // cached, and published as records to the user's PUBLIC repo. Note that
5066        // `classify_feed_privacy` does not catch these: both parse cleanly, and
5067        // it returns `Public` for anything unparseable by design.
5068        if !feed::is_storable_feed_url(&f.feed_url, state.config.standard_site) {
5069            info!(
5070                %did,
5071                "skipped an OPML entry whose xmlUrl is not a storable feed URL"
5072            );
5073            skipped_unsupported += 1;
5074            continue;
5075        }
5076        if let feed::FeedPrivacy::Private(reason) = feed::classify_feed_privacy(&f.feed_url) {
5077            info!(feed = %f.feed_url, %reason, %did, "skipped private/paid feed on OPML import (not stored)");
5078            // Report by title where we have one, else the (public-safe) host.
5079            let label = f
5080                .title
5081                .clone()
5082                .filter(|t| !t.trim().is_empty())
5083                .unwrap_or_else(|| private_feed_label(&f.feed_url));
5084            skipped_private.push(label);
5085            continue;
5086        }
5087
5088        // Over-cap: stop importing once headroom is exhausted (count the rest so
5089        // we can tell the user how many were dropped).
5090        if let Some(h) = headroom.as_mut() {
5091            if *h <= 0 {
5092                trimmed_over_cap += 1;
5093                continue;
5094            }
5095        }
5096
5097        // Global ceiling: a brand-new feed URL consumes global headroom. Once
5098        // it's exhausted, refuse to cache further NEW feeds (existing URLs are
5099        // free — they add no row). Checked before decrementing the per-DID
5100        // headroom so a dropped feed doesn't burn the caller's own quota.
5101        let is_new = match store::get_feed_by_url(pool, &f.feed_url).await {
5102            Ok(existing) => existing.is_none(),
5103            // On a lookup error, treat as existing (don't consume global
5104            // headroom) but still allow the upsert to proceed.
5105            Err(err) => {
5106                warn!(%err, feed = %f.feed_url, "get_feed_by_url failed during OPML global-cap check");
5107                false
5108            }
5109        };
5110        if is_new {
5111            if let Some(g) = global_headroom.as_mut() {
5112                if *g <= 0 {
5113                    trimmed_over_global += 1;
5114                    continue;
5115                }
5116                *g -= 1;
5117            }
5118        }
5119
5120        // Passed both caps: consume the per-DID headroom now that the feed is
5121        // actually being imported.
5122        if let Some(h) = headroom.as_mut() {
5123            *h -= 1;
5124        }
5125
5126        let mut sub = Subscription::new(f.feed_url.clone(), now.clone());
5127        sub.title = f.title.clone();
5128        sub.site_url = f.site_url.clone();
5129        sub.folder = f
5130            .folder
5131            .as_ref()
5132            .and_then(|name| folder_uris.get(name).cloned());
5133        subs.push(sub);
5134        // Same support ticket as the single-add path: no `feeds` row means the
5135        // poller never selects this subscription, so the import looks like it
5136        // worked and the feed silently never updates. Counted as well as logged,
5137        // because one line per feed in a 200-feed import is not something anyone
5138        // reads — the count goes to the reader.
5139        if let Err(err) = store::upsert_feed(
5140            pool,
5141            &store::NewFeed {
5142                url: f.feed_url.clone(),
5143                title: f.title.clone(),
5144                site_url: f.site_url.clone(),
5145                ..Default::default()
5146            },
5147        )
5148        .await
5149        {
5150            warn!(%err, %did, url = %f.feed_url, "OPML import could not cache a feed; \
5151                                                  it will not be polled");
5152            uncached += 1;
5153        }
5154    }
5155
5156    // **A failed PDS write is not an import.**
5157    //
5158    // The subscriptions live in the reader's repo; a local `feeds` row is just a
5159    // poller hint. This used to `warn!` and then report "Imported N feeds"
5160    // regardless, so a total failure read as a total success — and the reader
5161    // would only discover otherwise on their next visit, with an empty sidebar.
5162    let pds_written = match state.repo().add_subscriptions_bulk(&did, &subs).await {
5163        Ok(rkeys) => {
5164            info!(%did, count = rkeys.len(), skipped = skipped_private.len(), "imported OPML subscriptions to PDS (batched)");
5165            true
5166        }
5167        Err(err) => {
5168            warn!(%err, %did, "OPML PDS batch write failed (feeds cached locally)");
5169            false
5170        }
5171    };
5172    if !pds_written {
5173        return Ok(Redirect::to(&format!(
5174            "/?flash={}",
5175            qenc(
5176                "Could not save those subscriptions to your PDS, so nothing was imported. \
5177                 Try again in a moment."
5178            )
5179        ))
5180        .into_response());
5181    }
5182
5183    // Report the import count, plus any private/paid feeds skipped as unsupported.
5184    let mut flash = format!("Imported {} feeds", subs.len());
5185    if uncached > 0 {
5186        flash.push_str(&format!(
5187            ". {uncached} of them could not be cached locally and may not update until the next import."
5188        ));
5189    }
5190    if trimmed_over_cap > 0 {
5191        flash.push_str(&format!(
5192            ". {trimmed_over_cap} feed(s) not imported: your subscription limit ({sub_cap}) was reached."
5193        ));
5194    }
5195    if trimmed_over_global > 0 {
5196        flash.push_str(&format!(
5197            ". {trimmed_over_global} feed(s) not imported: this instance is at its feed capacity right now."
5198        ));
5199    }
5200    if !skipped_private.is_empty() {
5201        flash.push_str(&format!(
5202            ". {} feed(s) skipped as private/paid: {} — not supported yet (public feeds only for now).",
5203            skipped_private.len(),
5204            skipped_private.join(", ")
5205        ));
5206    }
5207    if skipped_unsupported > 0 {
5208        // By count only — the URL is whatever the file said, and unlike the
5209        // private branch there is no public-safe label to give.
5210        flash.push_str(&format!(
5211            ". {skipped_unsupported} feed(s) skipped: not a kind of feed this instance can subscribe to."
5212        ));
5213    }
5214    Ok(Redirect::to(&format!("/?flash={}", qenc(&flash))).into_response())
5215}
5216
5217/// A public-safe label for a skipped private feed when it has no title: just the
5218/// host, so we never echo the secret-bearing path/query back to the user.
5219fn private_feed_label(url: &str) -> String {
5220    url::Url::parse(url)
5221        .ok()
5222        .and_then(|u| u.host_str().map(str::to_string))
5223        .unwrap_or_else(|| "a private feed".to_string())
5224}
5225
5226/// `GET /opml/export` — export the user's subscriptions + folders as OPML.
5227async fn export_opml(
5228    State(state): State<AppState>,
5229    headers: HeaderMap,
5230) -> Result<Response, WebError> {
5231    let did = match current_did(&state, &headers).await {
5232        Some(d) => d,
5233        None => return Ok(Redirect::to("/login").into_response()),
5234    };
5235
5236    // **An export must never be silently empty.** `unwrap_or_default` here turned
5237    // a failed read into a 200 carrying a zero-feed OPML file — the reader's
5238    // backup, blank, at exactly the moment they reached for it. That was survivable
5239    // while a truncated walk returned `Ok`; now that the walk refuses a short list,
5240    // this is the one caller that converts a refusal into data loss, and it is also
5241    // the recovery route the changelog points a locked-out reader at.
5242    let subs = match state.repo().list_subscriptions_sorted(&did).await {
5243        Ok(subs) => subs,
5244        Err(err) => {
5245            tracing::warn!(%err, did = %did, "refusing to export an OPML we could not read in full");
5246            return Ok(Redirect::to(&format!(
5247                "/manage?flash={}",
5248                qenc(EXPORT_INCOMPLETE_REFUSAL)
5249            ))
5250            .into_response());
5251        }
5252    };
5253    let folders = match state.repo().list_folders_sorted(&did).await {
5254        Ok(folders) => folders,
5255        Err(err) => {
5256            tracing::warn!(%err, did = %did, "refusing to export an OPML without its folders");
5257            return Ok(Redirect::to(&format!(
5258                "/manage?flash={}",
5259                qenc(EXPORT_INCOMPLETE_REFUSAL)
5260            ))
5261            .into_response());
5262        }
5263    };
5264    // The exporter matches a subscription's `folder` at-uri against the folder's
5265    // pair key; our folder pairs are keyed by rkey, so rebuild them as at-uris.
5266    let folder_pairs: Vec<(String, Folder)> = folders
5267        .into_iter()
5268        .map(|(rkey, f)| (folder_uri(&did, &rkey), f))
5269        .collect();
5270
5271    let body = opml::to_opml(&subs, &folder_pairs);
5272    let mut resp = (StatusCode::OK, body).into_response();
5273    resp.headers_mut().insert(
5274        header::CONTENT_TYPE,
5275        "text/x-opml; charset=utf-8".parse().unwrap(),
5276    );
5277    resp.headers_mut().insert(
5278        header::CONTENT_DISPOSITION,
5279        "attachment; filename=\"featherreader-subscriptions.opml\""
5280            .parse()
5281            .unwrap(),
5282    );
5283    Ok(resp)
5284}
5285
5286// ---------------------------------------------------------------------------
5287// Signed session cookie (HMAC-SHA256, dependency-free)
5288// ---------------------------------------------------------------------------
5289
5290/// Set a `Set-Cookie` header on a response (append, so logout+redirect compose).
5291fn set_cookie(resp: &mut Response, cookie: &str) {
5292    if let Ok(value) = axum::http::HeaderValue::from_str(cookie) {
5293        resp.headers_mut()
5294            .append(axum::http::header::SET_COOKIE, value);
5295    }
5296}
5297
5298/// Whether the request came from htmx (the `HX-Request` header).
5299fn is_htmx(headers: &HeaderMap) -> bool {
5300    headers
5301        .get("HX-Request")
5302        .is_some_and(|v| v.as_bytes().eq_ignore_ascii_case(b"true"))
5303}
5304
5305/// Whether a mark-read / star request originated from the single-entry READER
5306/// (as opposed to the list view). The reader's forms tag themselves with
5307/// `X-FR-Reader: 1` via `hx-headers`; the list view's do not. This selects the
5308/// swap fragment: the reader gets an out-of-band action-bar update (its `<li>`
5309/// isn't in the DOM), the list gets the row (`entry_row.html`).
5310fn is_reader_request(headers: &HeaderMap) -> bool {
5311    headers
5312        .get("X-FR-Reader")
5313        .is_some_and(|v| v.as_bytes() == b"1")
5314}
5315
5316/// A tiny, self-contained signed-cookie layer: HMAC-SHA256 over an opaque,
5317/// server-minted **session id** (never the DID — so the cookie can't be forged
5318/// from a resolved victim DID; forging it needs the HMAC secret *and* a live
5319/// server-side session id).
5320mod cookie {
5321    use super::{HeaderMap, SESSION_COOKIE};
5322
5323    /// Sign a session id into a `Set-Cookie` header value: `fr_session=<sid>.<sig>`.
5324    pub fn sign_session(sid: &str, secret: &str) -> String {
5325        sign_value(SESSION_COOKIE, sid, secret, 2_592_000)
5326    }
5327
5328    /// Verify the request's session cookie and return the session id it carries.
5329    pub fn verify_session(headers: &HeaderMap, secret: &str) -> Option<String> {
5330        verify_value(headers, SESSION_COOKIE, secret)
5331    }
5332
5333    /// The HMAC message binding the cookie NAME to its value (`name || 0x00 ||
5334    /// value`), so a signature minted for one cookie can't verify under another —
5335    /// e.g. a value validly signed as `fr_invite` is not accepted as `fr_session`.
5336    /// The NUL separator can't appear in a cookie name, so the encoding is
5337    /// unambiguous.
5338    fn cookie_hmac_msg(name: &str, value: &str) -> Vec<u8> {
5339        let mut msg = Vec::with_capacity(name.len() + 1 + value.len());
5340        msg.extend_from_slice(name.as_bytes());
5341        msg.push(0);
5342        msg.extend_from_slice(value.as_bytes());
5343        msg
5344    }
5345
5346    /// Sign an arbitrary string `value` into a `Set-Cookie` header for `name`,
5347    /// HMAC-SHA256 over `name || 0x00 || value`: `name=<b64url(value)>.<sig>`. The
5348    /// generic form behind both the session cookie and the short-lived invite
5349    /// cookie; domain-separating by name keeps a signature valid only for the
5350    /// cookie it was minted for.
5351    pub fn sign_value(name: &str, value: &str, secret: &str, max_age_secs: i64) -> String {
5352        let sig = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(name, value));
5353        let b64 = b64url_encode(value.as_bytes());
5354        format!(
5355            "{name}={b64}.{sig}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age={max_age_secs}"
5356        )
5357    }
5358
5359    /// Verify + read a value out of the named signed cookie (`None` on absent /
5360    /// tampered / forged / cross-cookie). The generic form behind both readers.
5361    pub fn verify_value(headers: &HeaderMap, name: &str, secret: &str) -> Option<String> {
5362        let raw = cookie_value(headers, name)?;
5363        let (b64, sig) = raw.split_once('.')?;
5364        let bytes = b64url_decode(b64)?;
5365        let value = String::from_utf8(bytes).ok()?;
5366        let expected = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(name, &value));
5367        if constant_time_eq(expected.as_bytes(), sig.as_bytes()) {
5368            Some(value)
5369        } else {
5370            None
5371        }
5372    }
5373
5374    /// Sign an arbitrary `value` into an opaque, URL-safe token string
5375    /// `b64url(value).<sig>` (HMAC-SHA256 over `label || 0x00 || value`). Unlike
5376    /// [`sign_value`] this is NOT a `Set-Cookie` header — it's a bare token for a
5377    /// URL query param (the bot's claim link). `label` domain-separates it from
5378    /// the cookies so a token can't be replayed as a cookie value.
5379    pub fn sign_token(label: &str, value: &str, secret: &str) -> String {
5380        let sig = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(label, value));
5381        let b64 = b64url_encode(value.as_bytes());
5382        format!("{b64}.{sig}")
5383    }
5384
5385    /// Verify a token minted by [`sign_token`] and return the wrapped value
5386    /// (`None` on tamper / forge / malformed). Constant-time signature compare.
5387    pub fn verify_token(label: &str, token: &str, secret: &str) -> Option<String> {
5388        let (b64, sig) = token.split_once('.')?;
5389        let bytes = b64url_decode(b64)?;
5390        let value = String::from_utf8(bytes).ok()?;
5391        let expected = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(label, &value));
5392        if constant_time_eq(expected.as_bytes(), sig.as_bytes()) {
5393            Some(value)
5394        } else {
5395            None
5396        }
5397    }
5398
5399    /// Pull one cookie value out of the `Cookie` request header.
5400    fn cookie_value(headers: &HeaderMap, name: &str) -> Option<String> {
5401        let header = headers.get(axum::http::header::COOKIE)?.to_str().ok()?;
5402        for part in header.split(';') {
5403            let part = part.trim();
5404            if let Some((k, v)) = part.split_once('=') {
5405                if k == name {
5406                    return Some(v.to_string());
5407                }
5408            }
5409        }
5410        None
5411    }
5412
5413    /// Constant-time byte comparison (avoid signature-timing leaks). Public
5414    /// within the module so the bot-secret bearer check reuses the exact same
5415    /// comparator as the cookie/token HMAC checks (one implementation to audit).
5416    pub fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
5417        if a.len() != b.len() {
5418            return false;
5419        }
5420        let mut diff = 0u8;
5421        for (x, y) in a.iter().zip(b.iter()) {
5422            diff |= x ^ y;
5423        }
5424        diff == 0
5425    }
5426
5427    // -- URL-safe base64 (no padding), std-only --------------------------------
5428
5429    const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
5430
5431    fn b64url_encode(input: &[u8]) -> String {
5432        let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
5433        for chunk in input.chunks(3) {
5434            let b = [
5435                chunk[0],
5436                *chunk.get(1).unwrap_or(&0),
5437                *chunk.get(2).unwrap_or(&0),
5438            ];
5439            let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | (b[2] as u32);
5440            out.push(B64[((n >> 18) & 63) as usize] as char);
5441            out.push(B64[((n >> 12) & 63) as usize] as char);
5442            if chunk.len() > 1 {
5443                out.push(B64[((n >> 6) & 63) as usize] as char);
5444            }
5445            if chunk.len() > 2 {
5446                out.push(B64[(n & 63) as usize] as char);
5447            }
5448        }
5449        out
5450    }
5451
5452    fn b64url_decode(input: &str) -> Option<Vec<u8>> {
5453        fn val(c: u8) -> Option<u32> {
5454            match c {
5455                b'A'..=b'Z' => Some((c - b'A') as u32),
5456                b'a'..=b'z' => Some((c - b'a' + 26) as u32),
5457                b'0'..=b'9' => Some((c - b'0' + 52) as u32),
5458                b'-' => Some(62),
5459                b'_' => Some(63),
5460                _ => None,
5461            }
5462        }
5463        let bytes = input.as_bytes();
5464        let mut out = Vec::with_capacity(input.len() / 4 * 3 + 2);
5465        for chunk in bytes.chunks(4) {
5466            let mut n = 0u32;
5467            let mut valid = 0;
5468            for (i, &c) in chunk.iter().enumerate() {
5469                n |= val(c)? << (18 - 6 * i);
5470                valid += 1;
5471            }
5472            out.push((n >> 16) as u8);
5473            if valid > 2 {
5474                out.push((n >> 8) as u8);
5475            }
5476            if valid > 3 {
5477                out.push(n as u8);
5478            }
5479        }
5480        Some(out)
5481    }
5482
5483    // -- HMAC-SHA256, std-only -------------------------------------------------
5484
5485    /// HMAC-SHA256(key, msg) as lowercase hex.
5486    fn hmac_sha256_hex(key: &[u8], msg: &[u8]) -> String {
5487        const BLOCK: usize = 64;
5488        let mut k = [0u8; BLOCK];
5489        if key.len() > BLOCK {
5490            let d = sha256(key);
5491            k[..32].copy_from_slice(&d);
5492        } else {
5493            k[..key.len()].copy_from_slice(key);
5494        }
5495        let mut ipad = [0x36u8; BLOCK];
5496        let mut opad = [0x5cu8; BLOCK];
5497        for i in 0..BLOCK {
5498            ipad[i] ^= k[i];
5499            opad[i] ^= k[i];
5500        }
5501        let mut inner = Vec::with_capacity(BLOCK + msg.len());
5502        inner.extend_from_slice(&ipad);
5503        inner.extend_from_slice(msg);
5504        let inner_hash = sha256(&inner);
5505        let mut outer = Vec::with_capacity(BLOCK + 32);
5506        outer.extend_from_slice(&opad);
5507        outer.extend_from_slice(&inner_hash);
5508        let mac = sha256(&outer);
5509        let mut hex = String::with_capacity(64);
5510        for b in mac {
5511            hex.push_str(&format!("{b:02x}"));
5512        }
5513        hex
5514    }
5515
5516    /// SHA-256 (FIPS 180-4), std-only.
5517    fn sha256(data: &[u8]) -> [u8; 32] {
5518        const K: [u32; 64] = [
5519            0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4,
5520            0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe,
5521            0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f,
5522            0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7,
5523            0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc,
5524            0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b,
5525            0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116,
5526            0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
5527            0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7,
5528            0xc67178f2,
5529        ];
5530        let mut h: [u32; 8] = [
5531            0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab,
5532            0x5be0cd19,
5533        ];
5534
5535        let bit_len = (data.len() as u64) * 8;
5536        let mut msg = data.to_vec();
5537        msg.push(0x80);
5538        while msg.len() % 64 != 56 {
5539            msg.push(0);
5540        }
5541        msg.extend_from_slice(&bit_len.to_be_bytes());
5542
5543        for block in msg.chunks(64) {
5544            let mut w = [0u32; 64];
5545            for i in 0..16 {
5546                w[i] = u32::from_be_bytes([
5547                    block[i * 4],
5548                    block[i * 4 + 1],
5549                    block[i * 4 + 2],
5550                    block[i * 4 + 3],
5551                ]);
5552            }
5553            for i in 16..64 {
5554                let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3);
5555                let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10);
5556                w[i] = w[i - 16]
5557                    .wrapping_add(s0)
5558                    .wrapping_add(w[i - 7])
5559                    .wrapping_add(s1);
5560            }
5561            let mut a = h;
5562            for i in 0..64 {
5563                let s1 = a[4].rotate_right(6) ^ a[4].rotate_right(11) ^ a[4].rotate_right(25);
5564                let ch = (a[4] & a[5]) ^ ((!a[4]) & a[6]);
5565                let t1 = a[7]
5566                    .wrapping_add(s1)
5567                    .wrapping_add(ch)
5568                    .wrapping_add(K[i])
5569                    .wrapping_add(w[i]);
5570                let s0 = a[0].rotate_right(2) ^ a[0].rotate_right(13) ^ a[0].rotate_right(22);
5571                let maj = (a[0] & a[1]) ^ (a[0] & a[2]) ^ (a[1] & a[2]);
5572                let t2 = s0.wrapping_add(maj);
5573                a[7] = a[6];
5574                a[6] = a[5];
5575                a[5] = a[4];
5576                a[4] = a[3].wrapping_add(t1);
5577                a[3] = a[2];
5578                a[2] = a[1];
5579                a[1] = a[0];
5580                a[0] = t1.wrapping_add(t2);
5581            }
5582            for i in 0..8 {
5583                h[i] = h[i].wrapping_add(a[i]);
5584            }
5585        }
5586
5587        let mut out = [0u8; 32];
5588        for (i, word) in h.iter().enumerate() {
5589            out[i * 4..i * 4 + 4].copy_from_slice(&word.to_be_bytes());
5590        }
5591        out
5592    }
5593
5594    #[cfg(test)]
5595    mod tests {
5596        use super::*;
5597
5598        #[test]
5599        fn sha256_known_vector() {
5600            let d = sha256(b"abc");
5601            let hex: String = d.iter().map(|b| format!("{b:02x}")).collect();
5602            assert_eq!(
5603                hex,
5604                "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
5605            );
5606        }
5607
5608        #[test]
5609        fn hmac_known_vector() {
5610            let mac = hmac_sha256_hex(b"Jefe", b"what do ya want for nothing?");
5611            assert_eq!(
5612                mac,
5613                "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843"
5614            );
5615        }
5616
5617        #[test]
5618        fn sign_verify_round_trips() {
5619            let secret = "test-secret";
5620            let sid = "9f2c-opaque-session-id";
5621            let cookie = sign_session(sid, secret);
5622            let pair = cookie.split(';').next().unwrap().to_string();
5623            let mut headers = HeaderMap::new();
5624            headers.insert(axum::http::header::COOKIE, pair.parse().unwrap());
5625            assert_eq!(verify_session(&headers, secret).as_deref(), Some(sid));
5626            // Wrong secret → rejected (an attacker without the HMAC key can't forge).
5627            assert!(verify_session(&headers, "other-secret").is_none());
5628        }
5629
5630        #[test]
5631        fn forged_and_tampered_cookies_are_rejected() {
5632            let secret = "test-secret";
5633
5634            // 1. A fully forged cookie: attacker knows a victim's DID/sid but not
5635            //    the secret, so an arbitrary signature must not verify.
5636            let forged = format!(
5637                "{SESSION_COOKIE}={}.{}",
5638                b64url_encode(b"attacker-chosen-sid"),
5639                "deadbeef".repeat(8) // 64 hex chars, wrong sig
5640            );
5641            let mut headers = HeaderMap::new();
5642            headers.insert(axum::http::header::COOKIE, forged.parse().unwrap());
5643            assert!(verify_session(&headers, secret).is_none());
5644
5645            // 2. A tampered cookie: take a VALID cookie and mutate the sid while
5646            //    keeping the original signature — must not verify.
5647            let cookie = sign_session("real-sid", secret);
5648            let pair = cookie.split(';').next().unwrap();
5649            let (_b64, sig) = pair.split_once('=').unwrap().1.split_once('.').unwrap();
5650            let tampered = format!(
5651                "{SESSION_COOKIE}={}.{}",
5652                b64url_encode(b"different-sid"),
5653                sig
5654            );
5655            let mut headers2 = HeaderMap::new();
5656            headers2.insert(axum::http::header::COOKIE, tampered.parse().unwrap());
5657            assert!(verify_session(&headers2, secret).is_none());
5658        }
5659
5660        #[test]
5661        fn b64url_round_trips() {
5662            for s in ["did:plc:abc", "", "a", "ab", "abc", "abcd"] {
5663                let enc = b64url_encode(s.as_bytes());
5664                assert_eq!(b64url_decode(&enc).unwrap(), s.as_bytes());
5665            }
5666        }
5667    }
5668}
5669
5670// ---------------------------------------------------------------------------
5671// Small store helpers local to the web layer
5672// ---------------------------------------------------------------------------
5673
5674/// Fetch a single cached entry by id — SCOPED to `did`'s subscriptions.
5675///
5676/// Returns `None` (→ 404 at the handler) if the entry does not exist OR if
5677/// `did` does not subscribe to its feed. This is the per-DID read gate for the
5678/// `GET /entries/:id` reader and the htmx row rebuild: the shared cache is
5679/// deduped by URL, but no DID can read another DID's cached article.
5680///
5681/// **The only `SELECT e.*` left, and deliberately so.** This is the one surface
5682/// that renders `content_html`, and it fetches exactly one row. The list views
5683/// go through [`store::list_entries`], which is both paged and body-free — see
5684/// [`store::EntryListRow`] for why they had to stop sharing this projection.
5685async fn get_entry_by_id(
5686    pool: &store::Pool,
5687    did: &str,
5688    id: i64,
5689) -> anyhow::Result<Option<store::Entry>> {
5690    let entry = sqlx::query_as::<_, store::Entry>(
5691        r#"
5692        SELECT e.* FROM entries e
5693        WHERE e.id = ?2
5694          AND EXISTS (
5695              SELECT 1 FROM sub_ref sr
5696              WHERE sr.did = ?1 AND sr.feed_id = e.feed_id
5697          )
5698        "#,
5699    )
5700    .bind(did)
5701    .bind(id)
5702    .fetch_optional(pool)
5703    .await?;
5704    Ok(entry)
5705}
5706
5707/// Whether `entry_id` is marked read for `did` (absent state row = unread).
5708async fn entry_is_read(pool: &store::Pool, did: &str, entry_id: i64) -> anyhow::Result<bool> {
5709    let read: Option<bool> =
5710        sqlx::query_scalar("SELECT read FROM entry_state WHERE did = ?1 AND entry_id = ?2")
5711            .bind(did)
5712            .bind(entry_id)
5713            .fetch_optional(pool)
5714            .await?
5715            .flatten();
5716    Ok(read.unwrap_or(false))
5717}
5718
5719/// Whether `entry_id` is starred for `did` (absent state row = not starred).
5720async fn entry_is_starred(pool: &store::Pool, did: &str, entry_id: i64) -> anyhow::Result<bool> {
5721    let starred: Option<bool> =
5722        sqlx::query_scalar("SELECT starred FROM entry_state WHERE did = ?1 AND entry_id = ?2")
5723            .bind(did)
5724            .bind(entry_id)
5725            .fetch_optional(pool)
5726            .await?
5727            .flatten();
5728    Ok(starred.unwrap_or(false))
5729}
5730
5731/// Feed display title for one entry's feed id (via a single lookup).
5732async fn feed_title_by_entry(pool: &store::Pool, feed_id: i64) -> String {
5733    match sqlx::query_as::<_, store::Feed>("SELECT * FROM feeds WHERE id = ?1")
5734        .bind(feed_id)
5735        .fetch_optional(pool)
5736        .await
5737    {
5738        Ok(Some(f)) => display_title(f.title.as_deref(), &f.url),
5739        _ => String::new(),
5740    }
5741}
5742
5743/// Rebuild an [`EntryRow`] for an htmx swap after a read/star toggle. `read` may
5744/// be forced (mark-read path) or looked up (`None` — star path).
5745async fn build_entry_row(
5746    pool: &store::Pool,
5747    did: &str,
5748    id: i64,
5749    read: Option<bool>,
5750) -> anyhow::Result<Option<EntryRow>> {
5751    let entry = match get_entry_by_id(pool, did, id).await? {
5752        Some(e) => e,
5753        None => return Ok(None),
5754    };
5755    let read = match read {
5756        Some(r) => r,
5757        None => entry_is_read(pool, did, id).await?,
5758    };
5759    let starred = entry_is_starred(pool, did, id).await?;
5760    Ok(Some(EntryRow {
5761        id: entry.id,
5762        title: entry
5763            .title
5764            .clone()
5765            .filter(|t| !t.trim().is_empty())
5766            .unwrap_or_else(|| "(untitled)".to_string()),
5767        feed_title: feed_title_by_entry(pool, entry.feed_id).await,
5768        published: display_date(entry.published.as_deref()),
5769        read,
5770        starred,
5771        link: SafeLink::entry(id, ""),
5772        cached: true,
5773        rkey: String::new(),
5774    }))
5775}
5776
5777/// RFC3339 "now" (UTC) — shared by handlers that stamp/compare timestamps.
5778fn now_rfc3339() -> String {
5779    chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
5780}
5781
5782#[cfg(test)]
5783mod tests {
5784    use super::*;
5785
5786    #[test]
5787    fn qenc_encodes_reserved() {
5788        assert_eq!(qenc("a b"), "a%20b");
5789        assert_eq!(
5790            qenc("https://example.com/feed.xml"),
5791            "https%3A%2F%2Fexample.com%2Ffeed.xml"
5792        );
5793        assert_eq!(
5794            qenc("at://did:plc:x/c/r"),
5795            "at%3A%2F%2Fdid%3Aplc%3Ax%2Fc%2Fr"
5796        );
5797        // Unreserved chars pass through untouched.
5798        assert_eq!(qenc("A-Za-z0-9-_.~"), "A-Za-z0-9-_.~");
5799    }
5800
5801    #[test]
5802    fn folder_uri_shape() {
5803        assert_eq!(
5804            folder_uri("did:plc:abc", "3kfolder"),
5805            "at://did:plc:abc/community.lexicon.rss.folder/3kfolder"
5806        );
5807    }
5808
5809    // -- public-feeds-only: private/paid feeds are refused --------------------
5810
5811    #[test]
5812    fn private_feeds_are_classified_private_across_providers() {
5813        // The add + OPML paths both gate on this classifier; assert it flags a
5814        // spread of paid providers (newsletters + private podcasts) and the
5815        // generic credential-in-URL shapes.
5816        for url in [
5817            "https://author.substack.com/feed/private/deadbeefcafe1234",
5818            "https://www.patreon.com/rss/author?auth=Zm9vYmFyc2VjcmV0dG9rZW4",
5819            "https://blog.ghost.io/rss/?uuid=1f2e3d4c-5b6a-7089-90ab-cdef01234567",
5820            "https://feeds.supportingcast.fm/show/abcdef0123456789abcdef01",
5821            "https://example.com/feed?token=Zm9vYmFyc2VjcmV0",
5822            "https://user:pass@example.com/feed",
5823        ] {
5824            assert!(
5825                feed::classify_feed_privacy(url).is_private(),
5826                "expected private: {url}"
5827            );
5828        }
5829    }
5830
5831    #[test]
5832    fn public_feeds_stay_public() {
5833        for url in [
5834            "https://author.substack.com/feed",
5835            "https://wordpress.example.com/feed/",
5836            "https://example.com/rss.xml",
5837            "https://example.org/atom.xml",
5838            // YouTube channel/playlist RSS is fully public — must not false-block.
5839            "https://www.youtube.com/feeds/videos.xml?channel_id=UC-lHJZR3Gqxm24_Vd_AJ5Yw",
5840            "https://www.youtube.com/feeds/videos.xml?playlist_id=PLFgquLnL59alCl_2TQvOiD5Vgm1",
5841        ] {
5842            assert!(
5843                !feed::classify_feed_privacy(url).is_private(),
5844                "expected public: {url}"
5845            );
5846        }
5847    }
5848
5849    #[test]
5850    fn private_feed_label_is_public_safe_host_only() {
5851        // The OPML skip report must never echo the secret path/query, only the host.
5852        let label =
5853            private_feed_label("https://author.substack.com/feed/private/deadbeefcafe1234token");
5854        assert_eq!(label, "author.substack.com");
5855        assert!(!label.contains("deadbeefcafe1234token"));
5856        assert!(!label.contains("/private/"));
5857        // An unparseable URL degrades to a generic label.
5858        assert_eq!(private_feed_label("not a url"), "a private feed");
5859    }
5860
5861    #[test]
5862    fn refusal_message_promises_nothing_stored() {
5863        assert!(PRIVATE_FEED_REFUSAL.contains("not saved or sent anywhere"));
5864        assert!(PRIVATE_FEED_REFUSAL.contains("public feeds"));
5865    }
5866
5867    #[test]
5868    fn scope_query_preserves_context() {
5869        let q = EntryQuery {
5870            feed: Some("https://example.com/feed.xml".to_string()),
5871            folder: None,
5872            view: Some("all".to_string()),
5873        };
5874        let s = scope_query(&q);
5875        assert!(s.contains("feed=https%3A%2F%2Fexample.com%2Ffeed.xml"));
5876        assert!(s.contains("view=all"));
5877
5878        // Default view is omitted.
5879        let q2 = EntryQuery {
5880            feed: None,
5881            folder: None,
5882            view: Some("unread".to_string()),
5883        };
5884        assert_eq!(scope_query(&q2), "");
5885    }
5886
5887    // -- closed-beta invite gate + rate-limit + cache-control ------------------
5888
5889    use axum::body::Body;
5890    use axum::http::Request;
5891    use tower::ServiceExt; // for `oneshot`
5892
5893    /// Build an [`AppState`] over a fresh in-memory DB, seeding the given admin
5894    /// DIDs (via ALLOWED_DIDS → ensure_seed) and a fixed cookie secret so tests
5895    /// can forge matching cookies.
5896    async fn test_state(allowed: &[&str]) -> AppState {
5897        let db = store::init_url("sqlite::memory:").await.unwrap();
5898        let dids: Vec<String> = allowed.iter().map(|s| s.to_string()).collect();
5899        store::ensure_seed(&db, &dids).await.unwrap();
5900        let config = Config {
5901            allowed_dids: dids,
5902            cookie_secret: "test-cookie-secret-000".to_string(),
5903            beta_cap: 3,
5904            ..Config::default()
5905        };
5906        AppState::new(config, db).unwrap()
5907    }
5908
5909    /// A `Cookie` header carrying a valid signed session for `sid` (the sid is
5910    /// looked up in the registry, so create the session first).
5911    fn session_cookie(state: &AppState, did: &str, handle: Option<&str>) -> String {
5912        let sid = state.sessions.create(Session {
5913            did: did.to_string(),
5914            handle: handle.map(str::to_string),
5915        });
5916        let sc = cookie::sign_session(&sid, &state.config.cookie_secret);
5917        sc.split(';').next().unwrap().to_string()
5918    }
5919
5920    /// The bucket map is bounded by COUNT, not only by idle time. An hour is a
5921    /// long time to accept distinct source IPs on two unauthenticated guarded
5922    /// routes.
5923    #[test]
5924    fn the_rate_limit_map_is_bounded() {
5925        let rl = RateLimiter::shared();
5926        let now = Instant::now();
5927        for i in 0..(MAX_RATE_BUCKETS + 2_000) {
5928            // Distinct IPv6 addresses, all "seen" at increasing times so the LRU
5929            // ordering below is well-defined.
5930            let ip: IpAddr = format!("2001:db8::{i:x}").parse().unwrap();
5931            rl.check_at(ip, now + Duration::from_millis(i as u64));
5932        }
5933        let len = rl.inner.lock().unwrap().buckets.len();
5934        assert!(
5935            len <= MAX_RATE_BUCKETS,
5936            "the rate-limit map grew to {len}, past its {MAX_RATE_BUCKETS} cap"
5937        );
5938    }
5939
5940    /// Eviction must not hand a throttled attacker a fresh burst.
5941    ///
5942    /// The bound is LRU, so the one bucket an attacker can never evict is their
5943    /// own — it is the most recently touched thing in the map. If this inverted,
5944    /// the size cap would become a rate-limit bypass: spray addresses until the
5945    /// map overflows, then resume.
5946    #[test]
5947    fn flooding_the_map_does_not_reset_the_flooders_own_bucket() {
5948        let rl = RateLimiter::shared();
5949        let base = Instant::now();
5950        let attacker: IpAddr = "203.0.113.7".parse().unwrap();
5951        // Nanosecond steps: enough to keep the LRU ordering strictly increasing,
5952        // far too little for `RATE_REFILL_PER_SEC` to hand back a token. A
5953        // millisecond step made the whole flood take a second, and the refill —
5954        // working correctly — then looked exactly like an eviction bypass.
5955        let at = |n: u64| base + Duration::from_nanos(n);
5956
5957        // Spend the burst. `RATE_BURST` allowed, then refused.
5958        for i in 0..(RATE_BURST as u64) {
5959            assert!(rl.check_at(attacker, at(i)));
5960        }
5961        assert!(
5962            !rl.check_at(attacker, at(RATE_BURST as u64)),
5963            "burst was not exhausted; the rest of this test proves nothing"
5964        );
5965
5966        // Now overflow the map from other addresses, interleaving the attacker
5967        // so their bucket stays hot — the realistic shape of the attack.
5968        for i in 0..(MAX_RATE_BUCKETS + 2_000) {
5969            let t = at(100 + i as u64 * 2);
5970            let ip: IpAddr = format!("2001:db8:1::{i:x}").parse().unwrap();
5971            rl.check_at(ip, t);
5972            assert!(
5973                !rl.check_at(attacker, t),
5974                "the attacker got a token back after evictions at i={i}"
5975            );
5976        }
5977    }
5978
5979    /// The idle sweep is amortised, not per-request. It used to be an O(n) scan
5980    /// of the whole map on every guarded request, on one shared core.
5981    #[test]
5982    fn the_idle_sweep_does_not_run_on_every_request() {
5983        let rl = RateLimiter::shared();
5984        let start = Instant::now();
5985        let a: IpAddr = "198.51.100.1".parse().unwrap();
5986        let b: IpAddr = "198.51.100.2".parse().unwrap();
5987
5988        rl.check_at(a, start);
5989        // `b` arrives an hour later: `a` is now idle past `RATE_IDLE_EVICT`, but
5990        // the sweep interval has elapsed too, so this request does sweep it.
5991        rl.check_at(b, start + RATE_IDLE_EVICT + Duration::from_secs(1));
5992        assert!(
5993            !rl.inner.lock().unwrap().buckets.contains_key(&a),
5994            "an idle bucket survived a sweep that was due"
5995        );
5996
5997        // A second request moments later must NOT re-sweep — `b` is still there,
5998        // and the recorded sweep time must not have moved.
5999        let before = rl.inner.lock().unwrap().last_sweep;
6000        rl.check_at(b, start + RATE_IDLE_EVICT + Duration::from_secs(2));
6001        assert_eq!(
6002            rl.inner.lock().unwrap().last_sweep,
6003            before,
6004            "the sweep ran again within the interval"
6005        );
6006    }
6007
6008    #[test]
6009    fn rate_limited_paths_match_expected() {
6010        use axum::http::Method;
6011        assert!(is_rate_limited_path("/login", &Method::GET));
6012        assert!(is_rate_limited_path("/login", &Method::POST));
6013        assert!(is_rate_limited_path("/beta/redeem", &Method::POST));
6014        assert!(is_rate_limited_path("/subscriptions", &Method::POST));
6015        assert!(is_rate_limited_path("/opml", &Method::POST));
6016        assert!(is_rate_limited_path("/read-all", &Method::POST));
6017        assert!(is_rate_limited_path("/admin/invites", &Method::POST));
6018        assert!(is_rate_limited_path("/entries/42/read", &Method::POST));
6019        assert!(is_rate_limited_path("/entries/42/star", &Method::POST));
6020        // Read-only navigation is NOT limited.
6021        assert!(!is_rate_limited_path("/", &Method::GET));
6022        assert!(!is_rate_limited_path("/about", &Method::GET));
6023        assert!(!is_rate_limited_path("/entries/42", &Method::GET));
6024        assert!(!is_rate_limited_path("/login", &Method::HEAD));
6025    }
6026
6027    #[test]
6028    fn rate_limiter_allows_burst_then_429s() {
6029        let rl = RateLimiter::shared();
6030        let ip: IpAddr = "203.0.113.7".parse().unwrap();
6031        // The full burst passes.
6032        for _ in 0..(RATE_BURST as usize) {
6033            assert!(rl.check(ip));
6034        }
6035        // The next one (no time elapsed → no refill) is rejected.
6036        assert!(!rl.check(ip));
6037        // A different IP has its own bucket.
6038        let ip2: IpAddr = "203.0.113.8".parse().unwrap();
6039        assert!(rl.check(ip2));
6040    }
6041
6042    #[test]
6043    fn client_ip_ignores_spoofed_xff_without_trusted_header() {
6044        // With NO trusted header configured, a client-supplied X-Forwarded-For
6045        // must be ignored entirely — the limiter keys on the real socket peer,
6046        // so an attacker can't mint a fresh bucket per forged XFF value.
6047        let mut h = HeaderMap::new();
6048        h.insert("x-forwarded-for", "198.51.100.9, 10.0.0.1".parse().unwrap());
6049        let sock: SocketAddr = "203.0.113.55:1234".parse().unwrap();
6050        assert_eq!(
6051            client_ip(&h, Some(&sock), None),
6052            Some("203.0.113.55".parse().unwrap()),
6053            "spoofed XFF must not override the socket peer"
6054        );
6055    }
6056
6057    #[test]
6058    fn client_ip_uses_trusted_header_last_hop() {
6059        // With a trusted proxy header configured, the client IP comes from THAT
6060        // header (the proxy overwrites any client copy). On a comma list we take
6061        // the RIGHT-most hop — the one the trusted proxy appended — so a
6062        // client-forged left-most value is ignored.
6063        let sock: SocketAddr = "10.0.0.1:1234".parse().unwrap();
6064
6065        let mut h = HeaderMap::new();
6066        h.insert("fly-client-ip", "198.51.100.9".parse().unwrap());
6067        assert_eq!(
6068            client_ip(&h, Some(&sock), Some("fly-client-ip")),
6069            Some("198.51.100.9".parse().unwrap())
6070        );
6071
6072        // Attacker prepends a forged hop; the trusted proxy appends the real one.
6073        let mut h2 = HeaderMap::new();
6074        h2.insert("x-forwarded-for", "1.2.3.4, 198.51.100.9".parse().unwrap());
6075        assert_eq!(
6076            client_ip(&h2, Some(&sock), Some("x-forwarded-for")),
6077            Some("198.51.100.9".parse().unwrap()),
6078            "must take the right-most (trusted) hop, not the forged left-most"
6079        );
6080
6081        // Trusted header absent → fall back to the socket peer.
6082        let h3 = HeaderMap::new();
6083        assert_eq!(
6084            client_ip(&h3, Some(&sock), Some("fly-client-ip")),
6085            Some("10.0.0.1".parse().unwrap())
6086        );
6087    }
6088
6089    #[test]
6090    fn invite_cookie_round_trips_and_rejects_tamper() {
6091        let secret = "test-cookie-secret-000";
6092        let sc = sign_invite("FEATHER-ABCDWXYZ", secret);
6093        let pair = sc.split(';').next().unwrap();
6094        let mut h = HeaderMap::new();
6095        h.insert(header::COOKIE, pair.parse().unwrap());
6096        assert_eq!(
6097            invite_cookie_code(&h, secret).as_deref(),
6098            Some("FEATHER-ABCDWXYZ")
6099        );
6100        // Wrong secret → rejected.
6101        assert!(invite_cookie_code(&h, "other").is_none());
6102    }
6103
6104    #[tokio::test]
6105    async fn preflight_valid_expired_and_full() {
6106        let state = test_state(&["did:plc:admin"]).await;
6107        // A minted, active code preflights OK.
6108        let code = store::mint_code(&state.db, "did:plc:admin", 3600)
6109            .await
6110            .unwrap();
6111        assert!(preflight_code(&state, &code).await.is_ok());
6112
6113        // A code whose expiry is in the past preflights as Expired. (mint_code
6114        // clamps negative ttl to 0, so back-date the row directly for a
6115        // deterministic past expiry.)
6116        let expired = store::mint_code(&state.db, "did:plc:admin", 3600)
6117            .await
6118            .unwrap();
6119        sqlx::query("UPDATE invite_codes SET expires_at = ?1 WHERE code = ?2")
6120            .bind(chrono::Utc::now().timestamp() - 3600)
6121            .bind(&expired)
6122            .execute(&state.db)
6123            .await
6124            .unwrap();
6125        assert_eq!(
6126            preflight_code(&state, &expired).await,
6127            Err(store::RedeemError::Expired)
6128        );
6129
6130        // Unknown code → NotFound.
6131        assert_eq!(
6132            preflight_code(&state, "FEATHER-NOPENOPE").await,
6133            Err(store::RedeemError::NotFound)
6134        );
6135
6136        // Fill to cap (cap=3; the admin seed already took 1 seat) then preflight
6137        // must report CapacityFull.
6138        store::grant_access(&state.db, "did:plc:b", None, "admin", None)
6139            .await
6140            .unwrap();
6141        store::grant_access(&state.db, "did:plc:c", None, "admin", None)
6142            .await
6143            .unwrap();
6144        assert_eq!(store::count_beta_access(&state.db).await.unwrap(), 3);
6145        assert_eq!(
6146            preflight_code(&state, &code).await,
6147            Err(store::RedeemError::CapacityFull)
6148        );
6149    }
6150
6151    // -- Bot claim link + shared-secret mint ---------------------------------
6152
6153    /// A test state with a configured bot secret (so `/bot/claims` is live).
6154    async fn bot_state(bot_secret: &str) -> AppState {
6155        let db = store::init_url("sqlite::memory:").await.unwrap();
6156        store::ensure_seed(&db, &["did:plc:admin".to_string()])
6157            .await
6158            .unwrap();
6159        let config = Config {
6160            allowed_dids: vec!["did:plc:admin".to_string()],
6161            cookie_secret: "test-cookie-secret-000".to_string(),
6162            beta_cap: 3,
6163            bot_secret: Some(bot_secret.to_string()),
6164            public_url: "https://feather-reader.com".to_string(),
6165            ..Config::default()
6166        };
6167        AppState::new(config, db).unwrap()
6168    }
6169
6170    #[test]
6171    fn claim_token_round_trips_and_rejects_tamper() {
6172        let secret = "test-cookie-secret-000";
6173        let token = sign_claim_token("FEATHER-ABCDWXYZ", secret);
6174        // No cookie framing — a bare URL-safe token.
6175        assert!(!token.contains(';'));
6176        assert_eq!(
6177            claim_token_code(&token, secret).as_deref(),
6178            Some("FEATHER-ABCDWXYZ")
6179        );
6180        // Wrong secret → rejected.
6181        assert!(claim_token_code(&token, "other").is_none());
6182        // Tampered token → rejected.
6183        let mut bad = token.clone();
6184        bad.push('x');
6185        assert!(claim_token_code(&bad, secret).is_none());
6186        // The token is NOT confidential: it is `b64url(code).<sig>`, so the code is
6187        // only base64-obscured (not verbatim, but TRIVIALLY decodable — anyone can
6188        // recover it WITHOUT the secret). The security is single-use + HMAC
6189        // integrity + rate-limit, not secrecy of the code. Assert the code half is
6190        // publicly decodable (a plain base64url decode, no secret involved).
6191        let (b64, _sig) = token.split_once('.').expect("token is b64.sig");
6192        assert_eq!(
6193            test_b64url_decode(b64).as_deref(),
6194            Some("FEATHER-ABCDWXYZ".as_bytes()),
6195            "the code half of the token is plain base64url, decodable by anyone"
6196        );
6197    }
6198
6199    /// Minimal URL-safe base64 (no padding) decoder for the test above, proving the
6200    /// claim token's code half needs NO secret to recover (it is not confidential).
6201    fn test_b64url_decode(input: &str) -> Option<Vec<u8>> {
6202        fn val(c: u8) -> Option<u32> {
6203            match c {
6204                b'A'..=b'Z' => Some((c - b'A') as u32),
6205                b'a'..=b'z' => Some((c - b'a' + 26) as u32),
6206                b'0'..=b'9' => Some((c - b'0' + 52) as u32),
6207                b'-' => Some(62),
6208                b'_' => Some(63),
6209                _ => None,
6210            }
6211        }
6212        let mut out = Vec::with_capacity(input.len() / 4 * 3);
6213        for chunk in input.as_bytes().chunks(4) {
6214            let mut n = 0u32;
6215            let mut bits = 0;
6216            for &c in chunk {
6217                n = (n << 6) | val(c)?;
6218                bits += 6;
6219            }
6220            let bytes = bits / 8;
6221            n <<= 24 - bits;
6222            for i in 0..bytes {
6223                out.push((n >> (16 - i * 8)) as u8);
6224            }
6225        }
6226        Some(out)
6227    }
6228
6229    #[tokio::test]
6230    async fn bot_mint_then_claim_grants_a_seat() {
6231        let state = bot_state("bot-secret-abcdef").await;
6232        let app = router(state.clone());
6233
6234        // 1. Mint a claim via the shared-secret endpoint.
6235        let resp = app
6236            .clone()
6237            .oneshot(
6238                Request::builder()
6239                    .method("POST")
6240                    .uri("/bot/claims")
6241                    .header("x-bot-secret", "bot-secret-abcdef")
6242                    .body(Body::empty())
6243                    .unwrap(),
6244            )
6245            .await
6246            .unwrap();
6247        assert_eq!(resp.status(), StatusCode::OK);
6248        let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
6249            .await
6250            .unwrap();
6251        let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
6252        let token = json["token"].as_str().unwrap().to_string();
6253        let url = json["url"].as_str().unwrap();
6254        assert!(url.starts_with("https://feather-reader.com/claim?t="));
6255        // The raw code is returned for the bot's records but not embedded in url.
6256        assert!(json["code"].as_str().unwrap().starts_with("FEATHER-"));
6257        assert!(!url.contains("FEATHER-"));
6258
6259        // 2. Follow the claim link → reserves the invite cookie + redirects to /login.
6260        let resp = app
6261            .clone()
6262            .oneshot(
6263                Request::builder()
6264                    .method("GET")
6265                    .uri(format!("/claim?t={}", qenc(&token)))
6266                    .body(Body::empty())
6267                    .unwrap(),
6268            )
6269            .await
6270            .unwrap();
6271        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
6272        assert_eq!(resp.headers().get(header::LOCATION).unwrap(), "/login");
6273        let set_cookie = resp
6274            .headers()
6275            .get(header::SET_COOKIE)
6276            .unwrap()
6277            .to_str()
6278            .unwrap();
6279        assert!(set_cookie.starts_with(INVITE_COOKIE), "{set_cookie}");
6280
6281        // 3. The reserved cookie carries the same code the token wrapped, and
6282        //    redeeming it (the callback's machinery) grants a seat.
6283        let code = claim_token_code(&token, &state.config.cookie_secret).unwrap();
6284        let out = store::redeem_code(
6285            &state.db,
6286            &code,
6287            "did:plc:follower",
6288            None,
6289            state.config.beta_cap,
6290        )
6291        .await
6292        .unwrap();
6293        assert_eq!(out, Ok(()));
6294        assert!(store::has_beta_access(&state.db, "did:plc:follower")
6295            .await
6296            .unwrap());
6297    }
6298
6299    #[tokio::test]
6300    async fn claim_with_invalid_token_bounces() {
6301        let state = bot_state("bot-secret-abcdef").await;
6302        let app = router(state);
6303        let resp = app
6304            .oneshot(
6305                Request::builder()
6306                    .method("GET")
6307                    .uri("/claim?t=not-a-real-token")
6308                    .body(Body::empty())
6309                    .unwrap(),
6310            )
6311            .await
6312            .unwrap();
6313        // Renders the invite page (200), NOT a redirect to /login.
6314        assert_eq!(resp.status(), StatusCode::OK);
6315    }
6316
6317    #[tokio::test]
6318    async fn claim_with_used_token_is_refused() {
6319        let state = bot_state("bot-secret-abcdef").await;
6320        // Mint a code + wrap it, then redeem it out from under the token.
6321        let code = store::mint_code(&state.db, "did:plc:admin", 3600)
6322            .await
6323            .unwrap();
6324        let token = sign_claim_token(&code, &state.config.cookie_secret);
6325        store::redeem_code(
6326            &state.db,
6327            &code,
6328            "did:plc:someone",
6329            None,
6330            state.config.beta_cap,
6331        )
6332        .await
6333        .unwrap()
6334        .unwrap();
6335        let app = router(state);
6336        let resp = app
6337            .oneshot(
6338                Request::builder()
6339                    .method("GET")
6340                    .uri(format!("/claim?t={}", qenc(&token)))
6341                    .body(Body::empty())
6342                    .unwrap(),
6343            )
6344            .await
6345            .unwrap();
6346        // A used code → the invite page (AlreadyRedeemed), not a fresh reservation.
6347        assert_eq!(resp.status(), StatusCode::OK);
6348        assert!(resp.headers().get(header::SET_COOKIE).is_none());
6349    }
6350
6351    #[tokio::test]
6352    async fn bot_claims_rejects_bad_and_missing_secret() {
6353        let state = bot_state("bot-secret-abcdef").await;
6354        let app = router(state);
6355        // Wrong secret.
6356        let resp = app
6357            .clone()
6358            .oneshot(
6359                Request::builder()
6360                    .method("POST")
6361                    .uri("/bot/claims")
6362                    .header("x-bot-secret", "wrong")
6363                    .body(Body::empty())
6364                    .unwrap(),
6365            )
6366            .await
6367            .unwrap();
6368        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
6369        // Missing secret.
6370        let resp = app
6371            .oneshot(
6372                Request::builder()
6373                    .method("POST")
6374                    .uri("/bot/claims")
6375                    .body(Body::empty())
6376                    .unwrap(),
6377            )
6378            .await
6379            .unwrap();
6380        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
6381    }
6382
6383    #[tokio::test]
6384    async fn bot_claims_disabled_when_secret_unset() {
6385        // test_state configures NO bot secret → the endpoint is off (503).
6386        let state = test_state(&["did:plc:admin"]).await;
6387        let app = router(state);
6388        let resp = app
6389            .oneshot(
6390                Request::builder()
6391                    .method("POST")
6392                    .uri("/bot/claims")
6393                    .header("x-bot-secret", "anything")
6394                    .body(Body::empty())
6395                    .unwrap(),
6396            )
6397            .await
6398            .unwrap();
6399        assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
6400    }
6401
6402    #[tokio::test]
6403    async fn bot_claims_refuses_at_capacity() {
6404        let state = bot_state("bot-secret-abcdef").await;
6405        // cap=3, admin seed took 1 seat. Grant 2 more to fill it.
6406        store::grant_access(&state.db, "did:plc:b", None, "admin", None)
6407            .await
6408            .unwrap();
6409        store::grant_access(&state.db, "did:plc:c", None, "admin", None)
6410            .await
6411            .unwrap();
6412        assert_eq!(store::count_beta_access(&state.db).await.unwrap(), 3);
6413        let app = router(state);
6414        let resp = app
6415            .oneshot(
6416                Request::builder()
6417                    .method("POST")
6418                    .uri("/bot/claims")
6419                    .header("x-bot-secret", "bot-secret-abcdef")
6420                    .body(Body::empty())
6421                    .unwrap(),
6422            )
6423            .await
6424            .unwrap();
6425        assert_eq!(resp.status(), StatusCode::CONFLICT);
6426        let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
6427            .await
6428            .unwrap();
6429        assert!(String::from_utf8_lossy(&bytes).contains("full"));
6430    }
6431
6432    #[tokio::test]
6433    async fn bot_claims_counts_outstanding_codes_against_cap() {
6434        let state = bot_state("bot-secret-abcdef").await;
6435        // cap=3, admin seed = 1 seat. Two outstanding active codes = 3 committed.
6436        store::mint_code(&state.db, "did:plc:admin", 3600)
6437            .await
6438            .unwrap();
6439        store::mint_code(&state.db, "did:plc:admin", 3600)
6440            .await
6441            .unwrap();
6442        let app = router(state);
6443        let resp = app
6444            .oneshot(
6445                Request::builder()
6446                    .method("POST")
6447                    .uri("/bot/claims")
6448                    .header("x-bot-secret", "bot-secret-abcdef")
6449                    .body(Body::empty())
6450                    .unwrap(),
6451            )
6452            .await
6453            .unwrap();
6454        // 1 seat + 2 outstanding >= cap 3 → refused even though only 1 real seat used.
6455        assert_eq!(resp.status(), StatusCode::CONFLICT);
6456    }
6457
6458    /// POST /bot/claims with a JSON body carrying the follower DID.
6459    async fn post_bot_claim_for(
6460        app: &axum::Router,
6461        secret: &str,
6462        did: &str,
6463    ) -> (StatusCode, serde_json::Value) {
6464        let resp = app
6465            .clone()
6466            .oneshot(
6467                Request::builder()
6468                    .method("POST")
6469                    .uri("/bot/claims")
6470                    .header("x-bot-secret", secret)
6471                    .header("content-type", "application/json")
6472                    .body(Body::from(format!(
6473                        "{{\"did\":\"{did}\",\"handle\":\"who.test\"}}"
6474                    )))
6475                    .unwrap(),
6476            )
6477            .await
6478            .unwrap();
6479        let status = resp.status();
6480        let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
6481            .await
6482            .unwrap();
6483        let json = if bytes.is_empty() {
6484            serde_json::Value::Null
6485        } else {
6486            serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null)
6487        };
6488        (status, json)
6489    }
6490
6491    #[tokio::test]
6492    async fn bot_claims_returns_already_seated_for_a_member() {
6493        // A DID that already holds beta access must get `already_seated` with NO
6494        // code/url — the bot posts nothing. This is the server-side backstop that
6495        // survives a bot-host state loss (it would otherwise re-mint + re-post).
6496        let state = bot_state("bot-secret-abcdef").await;
6497        store::grant_access(&state.db, "did:plc:member", None, "admin", None)
6498            .await
6499            .unwrap();
6500        let app = router(state.clone());
6501        let (status, json) = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:member").await;
6502        assert_eq!(status, StatusCode::OK);
6503        assert_eq!(json["status"], "already_seated");
6504        assert_eq!(json["code"], "");
6505        assert_eq!(json["url"], "");
6506        // No new invite code was minted for the seated DID.
6507        assert!(store::find_active_code_for_did(&state.db, "did:plc:member")
6508            .await
6509            .unwrap()
6510            .is_none());
6511    }
6512
6513    #[tokio::test]
6514    async fn bot_claims_is_idempotent_per_did_returns_same_code() {
6515        // Two mint requests for the SAME follower DID must return the SAME code
6516        // (the app is authoritative), never a second one — so a bot-host state loss
6517        // re-requesting cannot double-mint or double-post.
6518        let state = bot_state("bot-secret-abcdef").await;
6519        let app = router(state.clone());
6520
6521        let (s1, j1) = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:follower1").await;
6522        assert_eq!(s1, StatusCode::OK);
6523        assert_eq!(j1["status"], "minted");
6524        let code1 = j1["code"].as_str().unwrap().to_string();
6525
6526        let (s2, j2) = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:follower1").await;
6527        assert_eq!(s2, StatusCode::OK);
6528        assert_eq!(j2["status"], "existing");
6529        assert_eq!(j2["code"].as_str().unwrap(), code1, "same code returned");
6530        assert_eq!(j2["url"], j1["url"], "same url returned");
6531
6532        // Exactly ONE active code exists for that DID.
6533        assert_eq!(store::count_active_codes(&state.db).await.unwrap(), 1);
6534    }
6535
6536    #[tokio::test]
6537    async fn bot_claims_records_intended_did_at_mint() {
6538        // A fresh mint records the follower DID so the lookup finds it.
6539        let state = bot_state("bot-secret-abcdef").await;
6540        let app = router(state.clone());
6541        let (status, json) =
6542            post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:follower2").await;
6543        assert_eq!(status, StatusCode::OK);
6544        let code = json["code"].as_str().unwrap();
6545        assert_eq!(
6546            store::find_active_code_for_did(&state.db, "did:plc:follower2")
6547                .await
6548                .unwrap()
6549                .as_deref(),
6550            Some(code)
6551        );
6552    }
6553
6554    #[tokio::test]
6555    async fn bot_claims_concurrent_same_did_never_double_mints() {
6556        // S4: two concurrent /bot/claims for ONE follower DID must not both mint an
6557        // active code. The dedupe check (3b) and the mint are separate statements,
6558        // so a race can slip both past 3b's `Ok(None)`; the partial unique index
6559        // then makes the loser's INSERT conflict, and the handler recovers by
6560        // returning the winner's code (status `existing`) rather than 500-ing.
6561        // Result: exactly ONE active code, and BOTH callers get a usable code.
6562        let state = bot_state("bot-secret-abcdef").await;
6563        let app = router(state.clone());
6564
6565        let a = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:racer");
6566        let b = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:racer");
6567        let ((sa, ja), (sb, jb)) = tokio::join!(a, b);
6568
6569        assert_eq!(sa, StatusCode::OK, "first response: {ja:?}");
6570        assert_eq!(sb, StatusCode::OK, "second response: {jb:?}");
6571
6572        // Exactly one active code for the DID — the whole point of the fix.
6573        assert_eq!(
6574            store::count_active_codes(&state.db).await.unwrap(),
6575            1,
6576            "concurrent mints must not create two active codes"
6577        );
6578
6579        // Both callers received the SAME (single) code, and neither got a 500.
6580        let ca = ja["code"].as_str().unwrap_or("");
6581        let cb = jb["code"].as_str().unwrap_or("");
6582        assert!(!ca.is_empty() && !cb.is_empty(), "both must return a code");
6583        assert_eq!(ca, cb, "both callers must get the one minted code");
6584        // One is `minted` (the winner), the other `minted` or `existing` depending
6585        // on interleaving — but never an error status.
6586        for st in [&ja["status"], &jb["status"]] {
6587            let s = st.as_str().unwrap_or("");
6588            assert!(s == "minted" || s == "existing", "unexpected status {s:?}");
6589        }
6590    }
6591
6592    #[tokio::test]
6593    async fn bot_claims_rejects_malformed_json_body() {
6594        let state = bot_state("bot-secret-abcdef").await;
6595        let app = router(state);
6596        let resp = app
6597            .oneshot(
6598                Request::builder()
6599                    .method("POST")
6600                    .uri("/bot/claims")
6601                    .header("x-bot-secret", "bot-secret-abcdef")
6602                    .header("content-type", "application/json")
6603                    .body(Body::from("{not json"))
6604                    .unwrap(),
6605            )
6606            .await
6607            .unwrap();
6608        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
6609    }
6610
6611    #[tokio::test]
6612    async fn favicon_ico_served_at_root() {
6613        // Browsers request bare /favicon.ico regardless of the <link rel="icon">
6614        // tags in <head>; the root route must serve the icon, not 404.
6615        let state = test_state(&[]).await;
6616        let app = router(state);
6617        let resp = app
6618            .oneshot(
6619                Request::builder()
6620                    .uri("/favicon.ico")
6621                    .body(Body::empty())
6622                    .unwrap(),
6623            )
6624            .await
6625            .unwrap();
6626        assert_eq!(resp.status(), StatusCode::OK);
6627        let ct = resp
6628            .headers()
6629            .get(header::CONTENT_TYPE)
6630            .unwrap()
6631            .to_str()
6632            .unwrap();
6633        assert!(
6634            ct.contains("icon") || ct.starts_with("image/"),
6635            "content-type = {ct}"
6636        );
6637    }
6638
6639    #[tokio::test]
6640    async fn login_without_invite_redirects_to_beta_redeem() {
6641        // No allow-list seed, no invite cookie: starting OAuth must be refused.
6642        let state = test_state(&[]).await;
6643        let app = router(state);
6644        let resp = app
6645            .oneshot(
6646                Request::builder()
6647                    .method("POST")
6648                    .uri("/login")
6649                    .header("content-type", "application/x-www-form-urlencoded")
6650                    .body(Body::from("handle=alice.bsky.social"))
6651                    .unwrap(),
6652            )
6653            .await
6654            .unwrap();
6655        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
6656        assert_eq!(
6657            resp.headers().get(header::LOCATION).unwrap(),
6658            "/beta/redeem"
6659        );
6660    }
6661
6662    #[tokio::test]
6663    async fn login_with_valid_invite_cookie_starts_oauth() {
6664        let state = test_state(&[]).await;
6665        let cookie = sign_invite("FEATHER-ABCDWXYZ", &state.config.cookie_secret);
6666        let cookie = cookie.split(';').next().unwrap().to_string();
6667        let app = router(state);
6668        let resp = app
6669            .oneshot(
6670                Request::builder()
6671                    .method("POST")
6672                    .uri("/login")
6673                    .header("content-type", "application/x-www-form-urlencoded")
6674                    .header(header::COOKIE, cookie)
6675                    .body(Body::from("handle=alice.bsky.social"))
6676                    .unwrap(),
6677            )
6678            .await
6679            .unwrap();
6680        // Redirects into the sidecar login (not to /beta/redeem).
6681        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
6682        let loc = resp
6683            .headers()
6684            .get(header::LOCATION)
6685            .unwrap()
6686            .to_str()
6687            .unwrap();
6688        assert!(loc.contains("/login"), "loc = {loc}");
6689        assert_ne!(loc, "/beta/redeem");
6690    }
6691
6692    /// A resolver that always fails — proves the fast paths short-circuit BEFORE
6693    /// any network resolution and that a resolution failure fails closed.
6694    async fn resolver_never(_handle: String) -> Option<String> {
6695        None
6696    }
6697
6698    /// A resolver that maps every handle to `did`.
6699    fn resolver_to(did: &'static str) -> impl FnOnce(String) -> std::future::Ready<Option<String>> {
6700        move |_handle| std::future::ready(Some(did.to_string()))
6701    }
6702
6703    /// Path 3: a cookie-less visitor whose submitted handle resolves to a DID
6704    /// that already holds a seat (the seeded-admin first-login case) passes the
6705    /// gate — no session cookie, no invite code.
6706    #[tokio::test]
6707    async fn may_start_oauth_honors_seat_via_resolved_handle() {
6708        // The seeded admin holds a seat (via ALLOWED_DIDS → ensure_seed) but has
6709        // no cookie on a fresh deploy.
6710        let state = test_state(&["did:plc:admin"]).await;
6711        let headers = HeaderMap::new();
6712        assert!(
6713            may_start_oauth_with(
6714                &state,
6715                &headers,
6716                "admin.example",
6717                resolver_to("did:plc:admin")
6718            )
6719            .await,
6720            "a handle resolving to a seated DID must pass the gate"
6721        );
6722    }
6723
6724    /// Path 3, negative: a handle that resolves to a DID with NO seat is bounced
6725    /// — the anti-abuse intent is preserved (resolution succeeds, seat check
6726    /// fails).
6727    #[tokio::test]
6728    async fn may_start_oauth_bounces_non_member_handle() {
6729        let state = test_state(&["did:plc:admin"]).await;
6730        let headers = HeaderMap::new();
6731        assert!(
6732            !may_start_oauth_with(
6733                &state,
6734                &headers,
6735                "rando.example",
6736                resolver_to("did:plc:rando")
6737            )
6738            .await,
6739            "a resolved DID with no seat must be bounced"
6740        );
6741    }
6742
6743    /// Fail-closed: an unresolvable/malformed handle (resolver returns `None`)
6744    /// bounces gracefully — no panic, no handshake.
6745    #[tokio::test]
6746    async fn may_start_oauth_fails_closed_on_unresolvable_handle() {
6747        let state = test_state(&["did:plc:admin"]).await;
6748        let headers = HeaderMap::new();
6749        assert!(
6750            !may_start_oauth_with(&state, &headers, "not a handle", resolver_never).await,
6751            "an unresolvable handle must fail closed"
6752        );
6753    }
6754
6755    /// The session-cookie fast path admits a seated member WITHOUT calling the
6756    /// resolver (proven by injecting `resolver_never`, which would otherwise
6757    /// bounce).
6758    #[tokio::test]
6759    async fn may_start_oauth_session_cookie_shortcircuits_resolution() {
6760        let state = test_state(&[]).await;
6761        let did = "did:plc:member";
6762        store::grant_access(&state.db, did, Some("member.example"), "test", None)
6763            .await
6764            .unwrap();
6765        let cookie = session_cookie(&state, did, Some("member.example"));
6766        let mut headers = HeaderMap::new();
6767        headers.insert(header::COOKIE, cookie.parse().unwrap());
6768        assert!(
6769            may_start_oauth_with(&state, &headers, "member.example", resolver_never).await,
6770            "a seated session cookie must pass without resolution"
6771        );
6772    }
6773
6774    /// The invite-cookie fast path admits WITHOUT calling the resolver.
6775    #[tokio::test]
6776    async fn may_start_oauth_invite_cookie_shortcircuits_resolution() {
6777        let state = test_state(&[]).await;
6778        let cookie = sign_invite("FEATHER-ABCDWXYZ", &state.config.cookie_secret);
6779        let cookie = cookie.split(';').next().unwrap().to_string();
6780        let mut headers = HeaderMap::new();
6781        headers.insert(header::COOKIE, cookie.parse().unwrap());
6782        assert!(
6783            may_start_oauth_with(&state, &headers, "someone.example", resolver_never).await,
6784            "a valid invite cookie must pass without resolution"
6785        );
6786    }
6787
6788    #[tokio::test]
6789    async fn admin_mint_requires_admin_seed_did() {
6790        let state = test_state(&["did:plc:admin"]).await;
6791        // A non-admin (but beta'd) session is forbidden.
6792        store::grant_access(&state.db, "did:plc:rando", None, "test", None)
6793            .await
6794            .unwrap();
6795        let rando_cookie = session_cookie(&state, "did:plc:rando", None);
6796        // An admin session is allowed.
6797        let admin_cookie = session_cookie(&state, "did:plc:admin", None);
6798        let app = router(state);
6799
6800        let forbidden = app
6801            .clone()
6802            .oneshot(
6803                Request::builder()
6804                    .method("POST")
6805                    .uri("/admin/invites?n=2")
6806                    .header(header::COOKIE, rando_cookie)
6807                    .body(Body::empty())
6808                    .unwrap(),
6809            )
6810            .await
6811            .unwrap();
6812        assert_eq!(forbidden.status(), StatusCode::FORBIDDEN);
6813
6814        let ok = app
6815            .oneshot(
6816                Request::builder()
6817                    .method("POST")
6818                    .uri("/admin/invites?n=2")
6819                    .header(header::COOKIE, admin_cookie)
6820                    .body(Body::empty())
6821                    .unwrap(),
6822            )
6823            .await
6824            .unwrap();
6825        assert_eq!(ok.status(), StatusCode::OK);
6826        let bytes = axum::body::to_bytes(ok.into_body(), 64 * 1024)
6827            .await
6828            .unwrap();
6829        let body = String::from_utf8(bytes.to_vec()).unwrap();
6830        let minted: Vec<&str> = body.lines().filter(|l| !l.is_empty()).collect();
6831        assert_eq!(minted.len(), 2);
6832        assert!(minted.iter().all(|c| c.starts_with("FEATHER-")));
6833    }
6834
6835    #[tokio::test]
6836    async fn admin_mint_unauthenticated_is_401() {
6837        let state = test_state(&["did:plc:admin"]).await;
6838        let app = router(state);
6839        let resp = app
6840            .oneshot(
6841                Request::builder()
6842                    .method("POST")
6843                    .uri("/admin/invites")
6844                    .body(Body::empty())
6845                    .unwrap(),
6846            )
6847            .await
6848            .unwrap();
6849        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
6850    }
6851
6852    /// A state whose `/about` renders the adoption line, seeded with one
6853    /// observation.
6854    async fn adoption_state(repos: i64, truncated: bool) -> AppState {
6855        let db = store::init_url("sqlite::memory:").await.unwrap();
6856        store::record_network_stat(
6857            &db,
6858            &store::NetworkStat {
6859                key: store::ADOPTION_STAT_KEY.to_string(),
6860                source: "https://relay1.us-west.bsky.network".to_string(),
6861                value: repos,
6862                truncated,
6863                observed_at: "2026-08-13T04:05:06Z".to_string(),
6864            },
6865        )
6866        .await
6867        .unwrap();
6868        let config = Config {
6869            cookie_secret: "test-cookie-secret-000".to_string(),
6870            show_adoption: true,
6871            ..Config::default()
6872        };
6873        AppState::new(config, db).unwrap()
6874    }
6875
6876    async fn about_body(state: AppState) -> String {
6877        let resp = router(state)
6878            .oneshot(
6879                Request::builder()
6880                    .uri("/about")
6881                    .body(Body::empty())
6882                    .unwrap(),
6883            )
6884            .await
6885            .unwrap();
6886        assert_eq!(resp.status(), StatusCode::OK);
6887        let bytes = axum::body::to_bytes(resp.into_body(), 256 * 1024)
6888            .await
6889            .unwrap();
6890        String::from_utf8(bytes.to_vec()).unwrap()
6891    }
6892
6893    /// Default config ⇒ the flag is off ⇒ no line, and no query is issued.
6894    #[tokio::test]
6895    async fn about_omits_adoption_line_by_default() {
6896        let state = test_state(&[]).await;
6897        assert!(!state.config.show_adoption);
6898        let body = about_body(state).await;
6899        assert!(
6900            !body.contains("atproto network"),
6901            "the adoption line must not render by default"
6902        );
6903    }
6904
6905    #[tokio::test]
6906    async fn about_renders_adoption_line_when_enabled() {
6907        // **A distinctive count, and asserted IN ITS SENTENCE.**
6908        //
6909        // This used to seed 4 and assert `body.contains("4")`, which the
6910        // colophon's `width="44"` satisfies whatever the count is — so
6911        // hardcoding the rendered number passed. Both halves are needed: a
6912        // digit that does not occur incidentally, and the assertion tied to the
6913        // phrase it belongs to.
6914        let body = about_body(adoption_state(7_318, false).await).await;
6915        // The count and its phrase are on separate template lines, so compare
6916        // against a whitespace-collapsed copy rather than the raw HTML.
6917        let flat = body.split_whitespace().collect::<Vec<_>>().join(" ");
6918        assert!(
6919            flat.contains("7318 accounts on the atproto network hold"),
6920            "the count did not render in its own sentence: {flat}",
6921        );
6922        assert!(
6923            body.contains("accounts on the atproto network hold"),
6924            "{body}"
6925        );
6926        assert!(
6927            body.contains("2026-08-13"),
6928            "the observation date must render"
6929        );
6930        assert!(
6931            body.contains("lower bound"),
6932            "the non-archival caveat must ride along with the number"
6933        );
6934        assert!(
6935            !body.contains("At least"),
6936            "an untruncated count is exact-ish"
6937        );
6938    }
6939
6940    /// A count of one must read as "1 account … holds", not "1 accounts … hold".
6941    #[tokio::test]
6942    async fn about_adoption_line_is_singular_at_one() {
6943        let body = about_body(adoption_state(1, false).await).await;
6944        assert!(
6945            body.contains("account on the atproto network holds"),
6946            "{body}"
6947        );
6948    }
6949
6950    /// A truncated observation is a floor, and must say so.
6951    #[tokio::test]
6952    async fn about_adoption_line_says_at_least_when_truncated() {
6953        let body = about_body(adoption_state(25_000, true).await).await;
6954        assert!(body.contains("At least"), "{body}");
6955    }
6956
6957    /// Flag on but no observation (or a zero) ⇒ 200, no line, no error.
6958    #[tokio::test]
6959    async fn about_omits_line_when_enabled_with_no_observation() {
6960        let db = store::init_url("sqlite::memory:").await.unwrap();
6961        let config = Config {
6962            cookie_secret: "test-cookie-secret-000".to_string(),
6963            show_adoption: true,
6964            ..Config::default()
6965        };
6966        let body = about_body(AppState::new(config, db).unwrap()).await;
6967        assert!(!body.contains("atproto network"));
6968    }
6969
6970    #[tokio::test]
6971    async fn cache_control_public_on_about_no_store_on_authed() {
6972        let state = test_state(&["did:plc:admin"]).await;
6973        let admin_cookie = session_cookie(&state, "did:plc:admin", None);
6974        let app = router(state);
6975
6976        // /about → public, cacheable.
6977        let about = app
6978            .clone()
6979            .oneshot(
6980                Request::builder()
6981                    .uri("/about")
6982                    .body(Body::empty())
6983                    .unwrap(),
6984            )
6985            .await
6986            .unwrap();
6987        assert_eq!(
6988            about.headers().get(header::CACHE_CONTROL).unwrap(),
6989            "public, max-age=300"
6990        );
6991        // The security headers are still intact.
6992        // The VALUE, spelled out here rather than compared to the constant —
6993        // `== CONTENT_SECURITY_POLICY` passes with the constant gutted. This
6994        // used to assert only that the header existed, which a policy of
6995        // `default-src *` satisfies.
6996        assert_eq!(
6997            about.headers()["content-security-policy"],
6998            EXPECTED_CSP,
6999            "the CSP is not the policy the router promises"
7000        );
7001        assert_eq!(about.headers().get("x-frame-options").unwrap(), "DENY");
7002
7003        // /privacy and /terms are static public pages → public, cacheable.
7004        for path in ["/privacy", "/terms"] {
7005            let resp = app
7006                .clone()
7007                .oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
7008                .await
7009                .unwrap();
7010            assert_eq!(resp.status(), StatusCode::OK);
7011            assert_eq!(
7012                resp.headers().get(header::CACHE_CONTROL).unwrap(),
7013                "public, max-age=300",
7014                "{path} should be publicly cacheable"
7015            );
7016            // Security headers apply to these pages too.
7017            assert_eq!(resp.headers()["content-security-policy"], EXPECTED_CSP);
7018            assert_eq!(resp.headers().get("x-frame-options").unwrap(), "DENY");
7019        }
7020
7021        // The bare /login landing → public, cacheable.
7022        let login = app
7023            .clone()
7024            .oneshot(
7025                Request::builder()
7026                    .uri("/login")
7027                    .body(Body::empty())
7028                    .unwrap(),
7029            )
7030            .await
7031            .unwrap();
7032        assert_eq!(
7033            login.headers().get(header::CACHE_CONTROL).unwrap(),
7034            "public, max-age=300"
7035        );
7036
7037        // An authenticated page → no-store.
7038        let home = app
7039            .oneshot(
7040                Request::builder()
7041                    .uri("/")
7042                    .header(header::COOKIE, admin_cookie)
7043                    .body(Body::empty())
7044                    .unwrap(),
7045            )
7046            .await
7047            .unwrap();
7048        assert_eq!(
7049            home.headers().get(header::CACHE_CONTROL).unwrap(),
7050            "no-store"
7051        );
7052    }
7053
7054    #[tokio::test]
7055    async fn beta_redeem_page_renders() {
7056        let state = test_state(&[]).await;
7057        let app = router(state);
7058        let resp = app
7059            .oneshot(
7060                Request::builder()
7061                    .uri("/beta/redeem")
7062                    .body(Body::empty())
7063                    .unwrap(),
7064            )
7065            .await
7066            .unwrap();
7067        assert_eq!(resp.status(), StatusCode::OK);
7068        let bytes = axum::body::to_bytes(resp.into_body(), 256 * 1024)
7069            .await
7070            .unwrap();
7071        let html = String::from_utf8(bytes.to_vec()).unwrap();
7072        assert!(html.contains("Invite code"));
7073        assert!(html.contains("/beta/redeem"));
7074    }
7075
7076    #[tokio::test]
7077    async fn rate_limit_returns_429_after_burst() {
7078        // Configure a trusted proxy header so the limiter keys on the forwarded
7079        // IP (the oneshot harness sets no ConnectInfo socket peer).
7080        let db = store::init_url("sqlite::memory:").await.unwrap();
7081        store::ensure_seed(&db, &[]).await.unwrap();
7082        let config = Config {
7083            cookie_secret: "test-cookie-secret-000".to_string(),
7084            beta_cap: 3,
7085            trusted_ip_header: Some("cf-connecting-ip".to_string()),
7086            ..Config::default()
7087        };
7088        let state = AppState::new(config, db).unwrap();
7089        let app = router(state);
7090        // Hammer POST /beta/redeem past the burst from a single (trusted) IP. The
7091        // handler itself returns 200 (re-render) on a bad code; the limiter is
7092        // what eventually yields 429.
7093        let mut saw_429 = false;
7094        for _ in 0..(RATE_BURST as usize + 5) {
7095            let resp = app
7096                .clone()
7097                .oneshot(
7098                    Request::builder()
7099                        .method("POST")
7100                        .uri("/beta/redeem")
7101                        .header("content-type", "application/x-www-form-urlencoded")
7102                        .header("cf-connecting-ip", "203.0.113.200")
7103                        .body(Body::from("code=FEATHER-NOPENOPE"))
7104                        .unwrap(),
7105                )
7106                .await
7107                .unwrap();
7108            if resp.status() == StatusCode::TOO_MANY_REQUESTS {
7109                saw_429 = true;
7110                break;
7111            }
7112        }
7113        assert!(saw_429, "expected a 429 after exhausting the burst");
7114    }
7115
7116    /// **A forged `X-Forwarded-For` does not key the limiter** — the property
7117    /// the middleware's comment cites this test as proof of.
7118    ///
7119    /// The previous version rotated the forged header and asserted that no
7120    /// request was EVER 429'd. Rotating addresses can never exhaust a per-IP
7121    /// burst, so that assertion held whether the header was trusted or
7122    /// ignored — it passed in the vulnerable configuration too. And with no
7123    /// socket peer the limiter fails open, so nothing could have been keyed on
7124    /// anything. Now one real peer sends `RATE_BURST + 5` requests, each with
7125    /// a DIFFERENT forged header, and the last must be 429: they all landed in
7126    /// the peer's bucket. A limiter keying on the header mints a fresh bucket
7127    /// per request and never trips — which is exactly what the mutation does.
7128    #[tokio::test]
7129    async fn a_forged_forwarded_for_header_does_not_key_the_limiter() {
7130        let state = test_state(&[]).await;
7131        assert!(
7132            state.config.trusted_ip_header.is_none(),
7133            "no proxy header is trusted here"
7134        );
7135        let app = router(state);
7136        let peer = std::net::SocketAddr::from(([203, 0, 113, 7], 40000));
7137        let mut saw_429 = false;
7138        for i in 0..(RATE_BURST as usize + 5) {
7139            let forged = format!("10.9.8.{}", i % 250);
7140            let resp = app
7141                .clone()
7142                .oneshot(
7143                    Request::builder()
7144                        .method("POST")
7145                        .uri("/beta/redeem")
7146                        .header("content-type", "application/x-www-form-urlencoded")
7147                        .header("x-forwarded-for", forged)
7148                        .extension(axum::extract::ConnectInfo(peer))
7149                        .body(Body::from("code=FEATHER-NOPENOPE"))
7150                        .unwrap(),
7151                )
7152                .await
7153                .unwrap();
7154            if resp.status() == StatusCode::TOO_MANY_REQUESTS {
7155                saw_429 = true;
7156                break;
7157            }
7158        }
7159        assert!(
7160            saw_429,
7161            "rotating a forged X-Forwarded-For minted fresh buckets: the limiter is keyed on an attacker-chosen header"
7162        );
7163    }
7164
7165    // -- OPML import body cap (DefaultBodyLimit → 413) -------------------------
7166
7167    /// **A private feed is refused BEFORE it is fetched.** The add path's
7168    /// privacy gate had no test at all — `private_feeds_are_classified_private_
7169    /// across_providers` says "the add + OPML paths both gate on this
7170    /// classifier" and nothing checked either. The gate exists so a
7171    /// token-bearing URL never reaches the network; the assertion that
7172    /// matters is the server's hit count: zero.
7173    #[tokio::test]
7174    async fn subscribing_to_a_private_feed_never_reaches_the_network() {
7175        let did = "did:plc:privateadder";
7176        let state = test_state_with_caps(did, 0, 0).await;
7177        let (base, hits) = crate::net::tests::serve_body_counted(b"<rss/>".to_vec()).await;
7178        let port: u16 = base
7179            .trim_end_matches('/')
7180            .rsplit(':')
7181            .next()
7182            .unwrap()
7183            .parse()
7184            .unwrap();
7185        crate::net::test_host_override(
7186            "private-add.test",
7187            std::net::SocketAddr::from(([127, 0, 0, 1], port)),
7188        );
7189        let cookie = session_cookie(&state, did, None);
7190        let resp = router(state.clone())
7191            .oneshot(
7192                Request::builder()
7193                    .method("POST")
7194                    .uri("/subscriptions")
7195                    .header(header::COOKIE, cookie)
7196                    .header("content-type", "application/x-www-form-urlencoded")
7197                    .body(Body::from(format!(
7198                        "url=http%3A%2F%2Fprivate-add.test%3A{port}%2Ffeed%2Fprivate%2Fdeadbeefcafe1234"
7199                    )))
7200                    .unwrap(),
7201            )
7202            .await
7203            .unwrap();
7204        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
7205        let loc = resp
7206            .headers()
7207            .get(header::LOCATION)
7208            .unwrap()
7209            .to_str()
7210            .unwrap();
7211        assert!(loc.contains("Private"), "not refused as private: {loc}");
7212        assert_eq!(
7213            hits.load(std::sync::atomic::Ordering::SeqCst),
7214            0,
7215            "the private feed was FETCHED before being refused"
7216        );
7217        assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
7218    }
7219
7220    /// **OPML import skips a private feed without storing or publishing it.**
7221    /// The import path does not fetch, so "never fetched" is not the signal
7222    /// here; "never stored, never written to the PDS" is. The batch write's
7223    /// bytes are captured and must not carry the URL.
7224    #[tokio::test]
7225    async fn opml_import_skips_a_private_feed_without_storing_or_publishing_it() {
7226        let did = "did:plc:renamer4";
7227        let (sidecar, bodies) = spawn_logging_sidecar().await;
7228        let state = test_state_with_sidecar(&[did], &sidecar).await;
7229        let tokened = "https://www.patreon.com/rss/author?auth=Zm9vYmFyc2VjcmV0dG9rZW4";
7230        let opml = format!(
7231            "<?xml version=\"1.0\"?>\n<opml version=\"2.0\"><head><title>t</title></head><body>\n\
7232             <outline type=\"rss\" text=\"Public\" xmlUrl=\"https://public.example/feed.xml\"/>\n\
7233             <outline type=\"rss\" text=\"Paid\" xmlUrl=\"{tokened}\"/>\n\
7234             </body></opml>"
7235        );
7236        let (ct, body) = opml_multipart(opml.as_bytes());
7237        let cookie = session_cookie(&state, did, None);
7238        let resp = router(state.clone())
7239            .oneshot(
7240                Request::builder()
7241                    .method("POST")
7242                    .uri("/opml")
7243                    .header(header::COOKIE, cookie)
7244                    .header("content-type", ct)
7245                    .body(Body::from(body))
7246                    .unwrap(),
7247            )
7248            .await
7249            .unwrap();
7250        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
7251        let loc = resp
7252            .headers()
7253            .get(header::LOCATION)
7254            .unwrap()
7255            .to_str()
7256            .unwrap();
7257        assert!(
7258            loc.contains("skipped%20as%20private"),
7259            "not reported as skipped: {loc}"
7260        );
7261        assert!(store::get_feed_by_url(&state.db, tokened)
7262            .await
7263            .unwrap()
7264            .is_none());
7265        let sent = bodies.lock().unwrap().join("\n");
7266        assert!(
7267            sent.contains("public.example"),
7268            "the public feed was not written: {sent}"
7269        );
7270        assert!(
7271            !sent.contains("Zm9vYmFyc2VjcmV0dG9rZW4"),
7272            "the secret was PUBLISHED to the PDS: {sent}"
7273        );
7274    }
7275
7276    /// **`GET /login?handle=` is gated like `POST /login`.** Only the POST was
7277    /// tested; the GET form starts the same handshake and had no test, so
7278    /// deleting its gate left the suite green.
7279    #[tokio::test]
7280    async fn get_login_without_a_seat_is_refused() {
7281        let state = test_state(&[]).await;
7282        let resp = router(state)
7283            .oneshot(
7284                Request::builder()
7285                    .method("GET")
7286                    .uri("/login?handle=alice.bsky.social")
7287                    .body(Body::empty())
7288                    .unwrap(),
7289            )
7290            .await
7291            .unwrap();
7292        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
7293        assert_eq!(
7294            resp.headers().get(header::LOCATION).unwrap(),
7295            "/beta/redeem"
7296        );
7297    }
7298
7299    /// A sidecar fake that answers every request `ok` and records the PATH of
7300    /// each in arrival order, plus every body — for asserting what was sent,
7301    /// and in what order.
7302    async fn spawn_logging_sidecar() -> (String, std::sync::Arc<std::sync::Mutex<Vec<String>>>) {
7303        use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
7304        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7305        let addr = listener.local_addr().unwrap();
7306        let log = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
7307        let sink = log.clone();
7308        tokio::spawn(async move {
7309            loop {
7310                let Ok((mut sock, _)) = listener.accept().await else {
7311                    break;
7312                };
7313                let mut raw: Vec<u8> = Vec::new();
7314                let mut chunk = [0u8; 4096];
7315                let text = loop {
7316                    let Ok(n) = sock.read(&mut chunk).await else {
7317                        break String::new();
7318                    };
7319                    if n == 0 {
7320                        break String::from_utf8_lossy(&raw).to_string();
7321                    }
7322                    raw.extend_from_slice(&chunk[..n]);
7323                    let Some(split) = raw.windows(4).position(|w| w == b"\r\n\r\n") else {
7324                        continue;
7325                    };
7326                    let (head, body) = raw.split_at(split + 4);
7327                    let want = String::from_utf8_lossy(head).lines().find_map(|l| {
7328                        let (k, v) = l.split_once(':')?;
7329                        k.eq_ignore_ascii_case("content-length")
7330                            .then(|| v.trim().parse::<usize>().ok())?
7331                    });
7332                    if want.is_none_or(|w| body.len() >= w) {
7333                        break String::from_utf8_lossy(&raw).to_string();
7334                    }
7335                };
7336                let path = text
7337                    .lines()
7338                    .next()
7339                    .and_then(|l| l.split_whitespace().nth(1))
7340                    .unwrap_or("")
7341                    .to_string();
7342                let body_text = text
7343                    .split_once("\r\n\r\n")
7344                    .map(|(_, b)| b)
7345                    .unwrap_or("")
7346                    .to_string();
7347                sink.lock().unwrap().push(format!("{path} {body_text}"));
7348                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();
7349                let resp = format!(
7350                    "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
7351                    body.len(),
7352                    body
7353                );
7354                let _ = sock.write_all(resp.as_bytes()).await;
7355                let _ = sock.flush().await;
7356            }
7357        });
7358        (format!("http://{addr}"), log)
7359    }
7360
7361    /// **Sign-out flushes dirty read-state BEFORE it revokes — through the
7362    /// route.** The previous version of this test called
7363    /// `flush_before_revoke` and `revoke_everywhere` itself and asserted one
7364    /// flush attempt; its doc claimed deleting the call from the handler
7365    /// "drops that to zero", which was false — the handler was never run.
7366    /// Deleting the call left the suite green: #117 regressing in full, with
7367    /// the test named after it still passing. Now `POST /logout` is driven and
7368    /// the sidecar's log must show a repo write BEFORE the revoke.
7369    #[tokio::test]
7370    async fn signing_out_flushes_before_it_revokes_through_the_route() {
7371        let did = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
7372        let (sidecar, log) = spawn_logging_sidecar().await;
7373        let state = test_state_with_sidecar(&[did], &sidecar).await;
7374        crate::store::upsert_cursor(
7375            &state.db,
7376            &crate::store::ReadCursor {
7377                did: did.to_string(),
7378                feed_url: "https://example.com/feed.xml".into(),
7379                read_through: None,
7380                read_ids: "[\"1\"]".into(),
7381                unread_ids: "[]".into(),
7382                dirty: true,
7383                pds_created: false,
7384                updated_at: "2026-09-13T21:22:40Z".into(),
7385            },
7386        )
7387        .await
7388        .unwrap();
7389        let cookie = session_cookie(&state, did, None);
7390        let resp = router(state.clone())
7391            .oneshot(
7392                Request::builder()
7393                    .method("POST")
7394                    .uri("/logout")
7395                    .header(header::COOKIE, cookie)
7396                    .body(Body::empty())
7397                    .unwrap(),
7398            )
7399            .await
7400            .unwrap();
7401        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
7402
7403        let entries = log.lock().unwrap().clone();
7404        let flush = entries
7405            .iter()
7406            .position(|e| e.starts_with("/internal/repo "));
7407        let revoke = entries
7408            .iter()
7409            .position(|e| e.starts_with("/internal/revoke "));
7410        assert!(revoke.is_some(), "sign-out did not revoke: {entries:?}");
7411        assert!(
7412            flush.is_some(),
7413            "sign-out did not attempt a flush before revoking: {entries:?}"
7414        );
7415        assert!(
7416            flush < revoke,
7417            "the flush arrived AFTER the revoke — no session left to send it with: {entries:?}"
7418        );
7419    }
7420
7421    /// The policy, as a literal: the backstop the router calls "neutralises any
7422    /// XSS that slips past sanitization". `script-src 'self'` and no
7423    /// `'unsafe-inline'` on it are the two clauses that make it one.
7424    const EXPECTED_CSP: &str = "default-src 'self'; \
7425     script-src 'self'; \
7426     style-src 'self' 'unsafe-inline'; \
7427     img-src 'self' https: data:; \
7428     font-src 'self'; \
7429     connect-src 'self'; \
7430     form-action 'self'; \
7431     base-uri 'self'; \
7432     frame-ancestors 'none'; \
7433     object-src 'none'";
7434
7435    /// Build a `multipart/form-data` body carrying a single `file` field whose
7436    /// contents are `payload`, returning `(content_type, body_bytes)`.
7437    fn opml_multipart(payload: &[u8]) -> (String, Vec<u8>) {
7438        let boundary = "----featherreadertestboundary";
7439        let mut body = Vec::new();
7440        body.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
7441        body.extend_from_slice(
7442            b"Content-Disposition: form-data; name=\"file\"; filename=\"feeds.opml\"\r\n",
7443        );
7444        body.extend_from_slice(b"Content-Type: text/x-opml\r\n\r\n");
7445        body.extend_from_slice(payload);
7446        body.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes());
7447        (format!("multipart/form-data; boundary={boundary}"), body)
7448    }
7449
7450    #[tokio::test]
7451    async fn opml_import_oversize_upload_returns_413() {
7452        let state = test_state(&["did:plc:admin"]).await;
7453        let cookie = session_cookie(&state, "did:plc:admin", None);
7454        let app = router(state);
7455
7456        // A payload comfortably above the route cap.
7457        let payload = vec![b'a'; OPML_BODY_LIMIT + 1024];
7458        let (content_type, body) = opml_multipart(&payload);
7459
7460        let resp = app
7461            .oneshot(
7462                Request::builder()
7463                    .method("POST")
7464                    .uri("/opml")
7465                    .header("content-type", content_type)
7466                    .header(header::COOKIE, cookie)
7467                    .body(Body::from(body))
7468                    .unwrap(),
7469            )
7470            .await
7471            .unwrap();
7472        assert_eq!(
7473            resp.status(),
7474            StatusCode::PAYLOAD_TOO_LARGE,
7475            "an over-cap OPML upload must be rejected with 413, not collapsed to 500"
7476        );
7477    }
7478
7479    /// **The route's own cap is what refuses this, not the framework's.**
7480    ///
7481    /// `OPML_BODY_LIMIT` used to equal axum's `DefaultBodyLimit` (2 MiB), so
7482    /// the route's layer was a no-op — deleting it left every test green, and
7483    /// `opml_import_oversize_upload_returns_413` was really testing axum. The
7484    /// limit is 1 MiB now, strictly tighter, and this uploads a payload that
7485    /// sits BETWEEN the two: over ours, under the framework's. Only the
7486    /// route's layer can refuse it — remove the layer and this payload is
7487    /// accepted, which is also what demonstrates the framework's default is
7488    /// the larger of the two.
7489    #[tokio::test]
7490    async fn opml_import_over_the_route_cap_is_refused_below_the_framework_default() {
7491        let state = test_state(&["did:plc:admin"]).await;
7492        let cookie = session_cookie(&state, "did:plc:admin", None);
7493        let app = router(state);
7494
7495        // Between the two ceilings: the framework would accept this.
7496        let payload = vec![b'a'; (OPML_BODY_LIMIT + AXUM_DEFAULT_BODY_LIMIT) / 2];
7497        let (content_type, body) = opml_multipart(&payload);
7498
7499        let resp = app
7500            .oneshot(
7501                Request::builder()
7502                    .method("POST")
7503                    .uri("/opml")
7504                    .header("content-type", content_type)
7505                    .header(header::COOKIE, cookie)
7506                    .body(Body::from(body))
7507                    .unwrap(),
7508            )
7509            .await
7510            .unwrap();
7511        assert_eq!(
7512            resp.status(),
7513            StatusCode::PAYLOAD_TOO_LARGE,
7514            "a payload over the route's cap but under the framework's was accepted — \
7515             the route's own DefaultBodyLimit layer is not doing anything"
7516        );
7517    }
7518
7519    #[tokio::test]
7520    async fn opml_import_under_limit_upload_is_accepted() {
7521        let state = test_state(&["did:plc:admin"]).await;
7522        let cookie = session_cookie(&state, "did:plc:admin", None);
7523        let db = state.db.clone();
7524        let app = router(state);
7525
7526        // A small, valid OPML well under the cap: must be accepted (the handler
7527        // redirects to `/` or a flash), i.e. never 413.
7528        let opml = br#"<?xml version="1.0"?>
7529<opml version="2.0"><body>
7530  <outline text="Example" type="rss" xmlUrl="https://example.com/feed.xml"/>
7531</body></opml>"#;
7532        let (content_type, body) = opml_multipart(opml);
7533
7534        let resp = app
7535            .oneshot(
7536                Request::builder()
7537                    .method("POST")
7538                    .uri("/opml")
7539                    .header("content-type", content_type)
7540                    .header(header::COOKIE, cookie)
7541                    .body(Body::from(body))
7542                    .unwrap(),
7543            )
7544            .await
7545            .unwrap();
7546        // **Assert it was ACCEPTED, not merely that it was not a 413.**
7547        //
7548        // The old assertion was `assert_ne!(status, PAYLOAD_TOO_LARGE)`, which a
7549        // 500 satisfies — so making `import_opml` fail unconditionally left this
7550        // green. Three other OPML tests caught that mutation; the one whose name
7551        // promises to cover the under-cap case did not.
7552        assert_eq!(
7553            resp.status(),
7554            StatusCode::SEE_OTHER,
7555            "an under-cap OPML upload was not accepted (status {})",
7556            resp.status(),
7557        );
7558        // **303 alone is not acceptance.** `import_opml` redirects on several
7559        // FAILURES too — unparseable OPML, zero feeds found, every feed trimmed
7560        // by a cap — so an import that stored nothing satisfied the status check.
7561        let stored: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM feeds WHERE url = ?1")
7562            .bind("https://example.com/feed.xml")
7563            .fetch_one(&db)
7564            .await
7565            .unwrap();
7566        assert_eq!(stored, 1, "the upload was redirected but imported nothing");
7567        let location = resp
7568            .headers()
7569            .get(header::LOCATION)
7570            .and_then(|v| v.to_str().ok())
7571            .unwrap_or_default()
7572            .to_string();
7573        assert!(
7574            !location.starts_with("/login"),
7575            "the import bounced to login instead of being accepted: {location}",
7576        );
7577    }
7578
7579    #[tokio::test]
7580    async fn opml_import_logged_out_redirects_to_login() {
7581        // Logged-out callers are redirected before the body is consumed; assert
7582        // the auth short-circuit rather than a body-cap rejection.
7583        let state = test_state(&["did:plc:admin"]).await;
7584        let app = router(state);
7585
7586        let opml = b"<opml version=\"2.0\"><body></body></opml>";
7587        let (content_type, body) = opml_multipart(opml);
7588
7589        let resp = app
7590            .oneshot(
7591                Request::builder()
7592                    .method("POST")
7593                    .uri("/opml")
7594                    .header("content-type", content_type)
7595                    .body(Body::from(body))
7596                    .unwrap(),
7597            )
7598            .await
7599            .unwrap();
7600        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
7601        assert_eq!(resp.headers().get(header::LOCATION).unwrap(), "/login");
7602    }
7603
7604    // -- delete-my-data (POST /account/delete) --------------------------------
7605
7606    /// A one-shot mock sidecar: binds a loopback port, answers exactly one
7607    /// `POST /internal/revoke` with `{ok:true,…}`, and reports (via the returned
7608    /// channel) the DID it was asked to revoke. Enough to prove the delete
7609    /// handler triggers the sidecar revoke without pulling in an HTTP-mock crate.
7610    async fn spawn_revoke_sidecar() -> (String, tokio::sync::oneshot::Receiver<String>) {
7611        use tokio::io::{AsyncReadExt, AsyncWriteExt};
7612        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7613        let addr = listener.local_addr().unwrap();
7614        let (tx, rx) = tokio::sync::oneshot::channel::<String>();
7615        tokio::spawn(async move {
7616            let (mut sock, _) = listener.accept().await.unwrap();
7617            let mut buf = vec![0u8; 4096];
7618            let n = sock.read(&mut buf).await.unwrap();
7619            let req = String::from_utf8_lossy(&buf[..n]).to_string();
7620            // Pull the DID out of the JSON body (last line of the request).
7621            let did = req
7622                .split("\r\n\r\n")
7623                .nth(1)
7624                .and_then(|body| {
7625                    let v: serde_json::Value = serde_json::from_str(body.trim()).ok()?;
7626                    v.get("did")?.as_str().map(str::to_string)
7627                })
7628                .unwrap_or_default();
7629            let is_revoke = req.starts_with("POST /internal/revoke");
7630            let body = serde_json::json!({
7631                "ok": true, "did": did, "revoked": true, "hadSession": true
7632            })
7633            .to_string();
7634            let resp = format!(
7635                "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
7636                body.len(),
7637                body
7638            );
7639            sock.write_all(resp.as_bytes()).await.unwrap();
7640            sock.flush().await.unwrap();
7641            let _ = tx.send(if is_revoke { did } else { String::new() });
7642        });
7643        (format!("http://{addr}"), rx)
7644    }
7645
7646    /// Build an [`AppState`] whose sidecar points at `sidecar_url`.
7647    async fn test_state_with_sidecar(allowed: &[&str], sidecar_url: &str) -> AppState {
7648        let defaults = Config::default();
7649        test_state_with_sidecar_and(
7650            allowed,
7651            sidecar_url,
7652            defaults.standard_site,
7653            defaults.max_feeds_global,
7654        )
7655        .await
7656    }
7657
7658    /// [`test_state_with_sidecar`] with the standard.site flag and the global
7659    /// feeds ceiling chosen — the two settings the at:// paths branch on.
7660    async fn test_state_with_sidecar_and(
7661        allowed: &[&str],
7662        sidecar_url: &str,
7663        standard_site: bool,
7664        max_feeds_global: i64,
7665    ) -> AppState {
7666        let db = store::init_url("sqlite::memory:").await.unwrap();
7667        let dids: Vec<String> = allowed.iter().map(|s| s.to_string()).collect();
7668        store::ensure_seed(&db, &dids).await.unwrap();
7669        let mut config = Config {
7670            allowed_dids: dids,
7671            cookie_secret: "test-cookie-secret-000".to_string(),
7672            beta_cap: 3,
7673            standard_site,
7674            max_feeds_global,
7675            ..Config::default()
7676        };
7677        config.sidecar.public_url = sidecar_url.to_string();
7678        config.sidecar.internal_url = sidecar_url.to_string();
7679        AppState::new(config, db).unwrap()
7680    }
7681
7682    /// A confirmed `POST /account/delete` purges the caller's local rows, calls
7683    /// the sidecar revoke for that DID, and clears the session cookie.
7684    #[tokio::test]
7685    async fn account_delete_purges_rows_and_triggers_revoke() {
7686        let (sidecar_url, revoke_rx) = spawn_revoke_sidecar().await;
7687        let did = "did:plc:leaver";
7688        let state = test_state_with_sidecar(&[], &sidecar_url).await;
7689
7690        // Seed the DID with local rows across the per-DID tables.
7691        store::grant_access(&state.db, did, Some("leaver.example"), "test", None)
7692            .await
7693            .unwrap();
7694        store::replace_sub_refs(&state.db, did, &[]).await.unwrap();
7695        store::mint_code(&state.db, did, 3600).await.unwrap();
7696        assert!(store::has_beta_access(&state.db, did).await.unwrap());
7697
7698        let cookie = session_cookie(&state, did, Some("leaver.example"));
7699        let app = router(state.clone());
7700
7701        let resp = app
7702            .oneshot(
7703                Request::builder()
7704                    .method("POST")
7705                    .uri("/account/delete")
7706                    .header(header::COOKIE, cookie)
7707                    .header("content-type", "application/x-www-form-urlencoded")
7708                    .body(Body::from("confirm=DELETE"))
7709                    .unwrap(),
7710            )
7711            .await
7712            .unwrap();
7713
7714        // Signed out: redirect to /login with the cookie cleared.
7715        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
7716        assert!(resp
7717            .headers()
7718            .get(header::LOCATION)
7719            .unwrap()
7720            .to_str()
7721            .unwrap()
7722            .starts_with("/login"));
7723        let set_cookie = resp
7724            .headers()
7725            .get(header::SET_COOKIE)
7726            .unwrap()
7727            .to_str()
7728            .unwrap();
7729        assert!(set_cookie.contains("Max-Age=0"), "cookie must be cleared");
7730
7731        // The sidecar revoke was called for exactly this DID.
7732        //
7733        // BOUNDED. A bare `await` here meant a broken `revoke_everywhere` — one
7734        // that simply never called the sidecar — hung this test forever instead
7735        // of failing it: a wedged CI job rather than a red one, which is the
7736        // worse of the two signals because nobody reads it as a defect.
7737        let revoked_did = tokio::time::timeout(std::time::Duration::from_secs(10), revoke_rx)
7738            .await
7739            .expect("the sidecar revoke never fired; revoke_everywhere did not call it")
7740            .unwrap();
7741        assert_eq!(
7742            revoked_did, did,
7743            "sidecar revoke must fire for the caller DID"
7744        );
7745
7746        // Local rows are gone.
7747        assert!(!store::has_beta_access(&state.db, did).await.unwrap());
7748        let codes: i64 =
7749            sqlx::query_scalar("SELECT COUNT(*) FROM invite_codes WHERE creator_did = ?1")
7750                .bind(did)
7751                .fetch_one(&state.db)
7752                .await
7753                .unwrap();
7754        assert_eq!(codes, 0);
7755    }
7756
7757    /// An UN-confirmed `POST /account/delete` (wrong/blank `confirm`) deletes
7758    /// nothing and bounces back to /manage.
7759    #[tokio::test]
7760    async fn account_delete_without_confirm_is_a_noop() {
7761        let did = "did:plc:staying";
7762        let state = test_state(&[]).await;
7763        store::grant_access(&state.db, did, None, "test", None)
7764            .await
7765            .unwrap();
7766        let cookie = session_cookie(&state, did, None);
7767        let app = router(state.clone());
7768
7769        let resp = app
7770            .oneshot(
7771                Request::builder()
7772                    .method("POST")
7773                    .uri("/account/delete")
7774                    .header(header::COOKIE, cookie)
7775                    .header("content-type", "application/x-www-form-urlencoded")
7776                    .body(Body::from("confirm=nope"))
7777                    .unwrap(),
7778            )
7779            .await
7780            .unwrap();
7781
7782        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
7783        assert!(resp
7784            .headers()
7785            .get(header::LOCATION)
7786            .unwrap()
7787            .to_str()
7788            .unwrap()
7789            .starts_with("/manage"));
7790        // Nothing deleted.
7791        assert!(store::has_beta_access(&state.db, did).await.unwrap());
7792    }
7793
7794    /// PDS-outage authorization: when the sidecar is unreachable (as it is in
7795    /// this harness — the default sidecar URL is not served), a DID must STILL
7796    /// be unable to read or mutate an entry in a feed it does not subscribe to.
7797    /// This guards the `resolve_subscriptions` fallback: it must fail CLOSED
7798    /// (serve only the DID's own `sub_ref`), never widen the caller's surface to
7799    /// every cached feed.
7800    #[tokio::test]
7801    async fn pds_outage_does_not_widen_cross_did_access() {
7802        let did_a = "did:plc:aaaa";
7803        let state = test_state(&[]).await;
7804        store::grant_access(&state.db, did_a, None, "test", None)
7805            .await
7806            .unwrap();
7807
7808        // Shared cache: feed_a (A subscribes) + feed_b (A does NOT). An entry
7809        // lives in feed_b — the one A must never touch during the outage.
7810        let feed_a = store::upsert_feed(
7811            &state.db,
7812            &store::NewFeed {
7813                url: "https://a.example/feed.xml".to_string(),
7814                title: Some("A".to_string()),
7815                ..Default::default()
7816            },
7817        )
7818        .await
7819        .unwrap();
7820        let feed_b = store::upsert_feed(
7821            &state.db,
7822            &store::NewFeed {
7823                url: "https://b.example/feed.xml".to_string(),
7824                title: Some("B".to_string()),
7825                ..Default::default()
7826            },
7827        )
7828        .await
7829        .unwrap();
7830        store::insert_entries(
7831            &state.db,
7832            feed_b,
7833            &[store::NewEntry {
7834                guid: "b-1".to_string(),
7835                url: Some("https://b.example/1".to_string()),
7836                title: Some("B one".to_string()),
7837                published: Some("2026-07-11T00:00:00Z".to_string()),
7838                content_html: Some("<p>secret B body</p>".to_string()),
7839                ..Default::default()
7840            }],
7841            0,
7842        )
7843        .await
7844        .unwrap();
7845        // A subscribes ONLY to feed_a.
7846        store::replace_sub_refs(&state.db, did_a, &[feed_a])
7847            .await
7848            .unwrap();
7849        // Read B's entry id via a transient sub_ref, then drop it so only the
7850        // shared cache holds B's entry (no DID subscribes to feed_b anymore).
7851        store::replace_sub_refs(&state.db, "did:plc:bbbb", &[feed_b])
7852            .await
7853            .unwrap();
7854        let b_entry_id = store::entries_for_feed(&state.db, "did:plc:bbbb", feed_b)
7855            .await
7856            .unwrap()[0]
7857            .id;
7858        store::replace_sub_refs(&state.db, "did:plc:bbbb", &[])
7859            .await
7860            .unwrap();
7861
7862        let cookie = session_cookie(&state, did_a, None);
7863        let app = router(state.clone());
7864
7865        // GET /entries/{b} as A → 404 even during the outage.
7866        let get_b = app
7867            .clone()
7868            .oneshot(
7869                Request::builder()
7870                    .method("GET")
7871                    .uri(format!("/entries/{b_entry_id}"))
7872                    .header(header::COOKIE, cookie.clone())
7873                    .body(Body::empty())
7874                    .unwrap(),
7875            )
7876            .await
7877            .unwrap();
7878        assert_eq!(
7879            get_b.status(),
7880            StatusCode::NOT_FOUND,
7881            "A must not read B's entry during a PDS outage"
7882        );
7883
7884        // POST /entries/{b}/read as A → 404, and no entry_state row is written.
7885        let read_b = app
7886            .oneshot(
7887                Request::builder()
7888                    .method("POST")
7889                    .uri(format!("/entries/{b_entry_id}/read"))
7890                    .header(header::COOKIE, cookie)
7891                    .header("content-type", "application/x-www-form-urlencoded")
7892                    .body(Body::from("read=true"))
7893                    .unwrap(),
7894            )
7895            .await
7896            .unwrap();
7897        assert_eq!(
7898            read_b.status(),
7899            StatusCode::NOT_FOUND,
7900            "A must not mark B's entry read during a PDS outage"
7901        );
7902
7903        // The fallback must NOT have widened A's sub_ref to feed_b.
7904        let a_feed_ids: Vec<i64> = sqlx::query_scalar("SELECT feed_id FROM sub_ref WHERE did = ?1")
7905            .bind(did_a)
7906            .fetch_all(&state.db)
7907            .await
7908            .unwrap();
7909        assert_eq!(
7910            a_feed_ids,
7911            vec![feed_a],
7912            "outage fallback must not add feeds A never subscribed to"
7913        );
7914        // And B's entry has zero read-state (A's attempt did not mutate).
7915        let es_count: i64 =
7916            sqlx::query_scalar("SELECT COUNT(*) FROM entry_state WHERE did = ?1 AND entry_id = ?2")
7917                .bind(did_a)
7918                .bind(b_entry_id)
7919                .fetch_one(&state.db)
7920                .await
7921                .unwrap();
7922        assert_eq!(es_count, 0, "no cross-DID mutation during the outage");
7923    }
7924
7925    /// **A logout with nothing to revoke is a SUCCESS — on the arm that had
7926    /// nothing. The other arm is counted separately.**
7927    ///
7928    /// Logout is idempotent, so `NoSession` must record as ok; counting it as an
7929    /// error would make the metric noisy in exactly the case that is fine.
7930    ///
7931    /// But `revoke_everywhere` has TWO arms, and a review found that counting
7932    /// only the rust one let `oauth_revoke` report all-clear while every sidecar
7933    /// revocation failed. For anyone who logged in before the cutover the sidecar
7934    /// store is the only one holding tokens, so the rust arm correctly says
7935    /// NoSession and the metric said nothing was wrong. Both arms are now
7936    /// recorded, distinguished by the backend column — so this test pins the
7937    /// BACKEND as well as the outcome.
7938    #[tokio::test]
7939    async fn a_logout_with_no_session_counts_as_success() {
7940        let did = "did:plc:aaaa";
7941        let state = test_state(&[]).await;
7942        assert!(
7943            state.oauth.is_some(),
7944            "meaningless without an oauth runtime; the revoke arm would be skipped",
7945        );
7946
7947        revoke_everywhere(&state, did).await;
7948        let rows = state.metrics.snapshot();
7949        let find = |b: crate::metrics::Backend| {
7950            rows.iter()
7951                .find(|r| r.op == "oauth_revoke" && r.backend == b)
7952                .unwrap_or_else(|| panic!("no oauth_revoke row for {b:?}"))
7953        };
7954
7955        // Rust arm: nothing stored for this DID, so NoSession -> ok.
7956        let rust = find(crate::metrics::Backend::Rust);
7957        assert_eq!(
7958            rust.stats.err_count, 0,
7959            "NoSession was counted as a failure; logout is idempotent",
7960        );
7961        assert_eq!(rust.stats.ok_count, 1);
7962
7963        // Sidecar arm: unreachable in a test, so it must be recorded as an
7964        // ERROR under its own backend — not silently dropped, and not folded
7965        // into the rust row.
7966        let sidecar = find(crate::metrics::Backend::Sidecar);
7967        assert_eq!(
7968            sidecar.stats.err_count, 1,
7969            "a failed sidecar revoke was not counted",
7970        );
7971    }
7972
7973    /// **`Failed` must count as an error — the half the metric exists for.**
7974    ///
7975    /// A review found this unpinned: replacing the mapping with
7976    /// `let revoke_ok = true;` passed all 682 tests. The only revoke test
7977    /// asserted the `NoSession -> ok` half, so the branch that actually means
7978    /// "the PDS still holds tokens we asked it to drop" was untested.
7979    ///
7980    /// Driven through the same handler, with a session present but the PDS
7981    /// unreachable, so `sign_out_discovering` returns `Failed`.
7982    #[tokio::test]
7983    async fn a_failed_rust_revoke_counts_as_an_error() {
7984        let did = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
7985        let state = test_state(&[]).await;
7986        let runtime = state.oauth.as_deref().expect("oauth runtime");
7987        crate::oauth::store::put_session(
7988            &state.db,
7989            &runtime.codec,
7990            &crate::oauth::store::OAuthSession {
7991                sub: did.into(),
7992                issuer: "https://auth.invalid".into(),
7993                aud: "https://pds.invalid".into(),
7994                dpop_key_jwk: crate::oauth::keys::SigningKey::generate("session-dpop")
7995                    .to_jwk_json()
7996                    .unwrap(),
7997                access_token: "at".into(),
7998                refresh_token: "rt".into(),
7999                token_type: "DPoP".into(),
8000                granted_scope: "atproto".into(),
8001                expires_at: Some(crate::store::now_unix() + 3600),
8002            },
8003        )
8004        .await
8005        .unwrap();
8006
8007        revoke_everywhere(&state, did).await;
8008
8009        let rows = state.metrics.snapshot();
8010        let rust = rows
8011            .iter()
8012            .find(|r| r.op == "oauth_revoke" && r.backend == crate::metrics::Backend::Rust)
8013            .expect("no rust oauth_revoke row");
8014        assert_eq!(
8015            rust.stats.err_count, 1,
8016            "an unreachable PDS must count as a revocation failure",
8017        );
8018        assert_eq!(rust.stats.ok_count, 0);
8019    }
8020
8021    /// **The `href` defence is now carried by the TYPE, not by remembering.**
8022    ///
8023    /// `EntryRow.link` used to be a `String`, and the guard was "call
8024    /// `net::safe_link` before assigning it". Deleting that call left all 679
8025    /// tests passing — a live XSS defence with nothing protecting it.
8026    ///
8027    /// `SafeLink` has no `From<String>` and no public member, so the only way to
8028    /// get foreign input into an `href` is `external`, which does the check
8029    /// itself. This test pins that constructor; the *wiring* is now pinned by
8030    /// the compiler, which is the part a test could never hold down.
8031    ///
8032    /// Note the empty-not-absent behaviour: a rejected URL yields an EMPTY link
8033    /// so the template renders the row WITHOUT an anchor. Dropping the row
8034    /// instead would make the record unremovable, because the un-save button
8035    /// lives on it.
8036    #[test]
8037    fn a_hostile_scheme_cannot_reach_an_href_through_safelink() {
8038        for hostile in [
8039            "javascript:alert(1)",
8040            "JavaScript:alert(1)",
8041            "  javascript:alert(1)",
8042            "data:text/html;base64,PHNjcmlwdD4=",
8043            "vbscript:msgbox(1)",
8044            "file:///etc/passwd",
8045            // Protocol-relative: inherits the page's scheme, so it is an
8046            // off-site link wearing a same-site costume. Carried over from the
8047            // test this one replaces, which was its only unique input.
8048            "//evil.example/path",
8049        ] {
8050            let link = SafeLink::external(hostile);
8051            assert!(
8052                link.is_empty(),
8053                "{hostile:?} produced a non-empty href: {link}",
8054            );
8055            assert!(
8056                !link.to_string().to_ascii_lowercase().contains("script"),
8057                "{hostile:?} leaked into the rendered link",
8058            );
8059        }
8060
8061        // And the other direction: a check that rejects everything would satisfy
8062        // the loop above while breaking every real saved record.
8063        for good in ["https://example.com/a?b=c#d", "http://example.com/"] {
8064            let link = SafeLink::external(good);
8065            assert!(!link.is_empty(), "{good:?} was wrongly rejected");
8066            assert_eq!(link.to_string(), good);
8067        }
8068    }
8069
8070    /// **The WIRING, not the helper — this is the one that catches the real
8071    /// mistake.**
8072    ///
8073    /// `a_hostile_scheme_cannot_reach_an_href_through_safelink` pins what
8074    /// `SafeLink::external` *does*. It cannot pin that the saved-record path
8075    /// *calls* it, and a review proved that gap was live twice over: swapping
8076    /// `external` for the app-path constructor, and constructing the tuple
8077    /// directly, both restored the whole `javascript:` hole with every test
8078    /// green. The type now blocks both — `entry` takes an `i64`, and the field
8079    /// lives in another module — but the wiring deserves a test of its own
8080    /// rather than resting on the shape of a signature.
8081    ///
8082    /// Renders the actual row through the actual handler, from a record whose
8083    /// URL is hostile.
8084    #[tokio::test]
8085    async fn a_saved_record_with_a_hostile_url_renders_no_anchor() {
8086        let did = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
8087        let sidecar = spawn_saved_sidecar("javascript:alert(1)", "Hostile record").await;
8088        let mut state = test_state_with_sidecar(&[did], &sidecar).await;
8089        std::sync::Arc::get_mut(&mut state.config).unwrap().dev_did = Some(did.to_string());
8090
8091        let resp = router(state)
8092            .oneshot(
8093                Request::builder()
8094                    .uri("/?view=starred")
8095                    .body(Body::empty())
8096                    .unwrap(),
8097            )
8098            .await
8099            .unwrap();
8100        assert_eq!(resp.status(), StatusCode::OK);
8101        let body = String::from_utf8(
8102            axum::body::to_bytes(resp.into_body(), usize::MAX)
8103                .await
8104                .unwrap()
8105                .to_vec(),
8106        )
8107        .unwrap();
8108
8109        // Not in an href, and not as the title either — the title falls back to
8110        // the URL for links we DO render, so both paths must withhold it.
8111        assert!(
8112            !body.to_ascii_lowercase().contains("javascript:"),
8113            "the hostile scheme reached the rendered page",
8114        );
8115        // But the row must survive: the un-save button lives on it, so dropping
8116        // the row would make the record unremovable from here.
8117        assert!(
8118            body.contains("unusable link"),
8119            "the row was dropped instead of rendering without an anchor",
8120        );
8121    }
8122
8123    /// **The reader view's two `href`s, through the actual handler.**
8124    ///
8125    /// The sibling above covers the LIST row. `entry.html` has its own pair of
8126    /// `href`s fed by `EntryTemplate.url`, and they were a raw `Option<String>`
8127    /// taken straight off the `entries.url` column — a remote feed's `<link>`.
8128    ///
8129    /// Ingest already scheme-checks that column (`feed.rs`'s `entry_link`), so
8130    /// this was never a live hole. But that guard is procedural and sits a long
8131    /// way from the `href`: it holds only as long as every future writer to
8132    /// `entries.url` remembers to go through `feed.rs`. This test does not
8133    /// depend on it — it writes the hostile URL into the column DIRECTLY, which
8134    /// is precisely the state the ingest check cannot speak for.
8135    ///
8136    /// **Both directions, deliberately.** A fix that renders no link at all
8137    /// satisfies every negative assertion here, and would break every real
8138    /// entry. The second half is what makes the first half mean something.
8139    #[tokio::test]
8140    async fn a_hostile_entry_url_renders_the_reader_without_an_original_link() {
8141        let did = "did:plc:readerhref";
8142        let state = test_state(&[]).await;
8143        store::grant_access(&state.db, did, None, "test", None)
8144            .await
8145            .unwrap();
8146        let feed = store::upsert_feed(
8147            &state.db,
8148            &store::NewFeed {
8149                url: "https://href.example/feed.xml".to_string(),
8150                title: Some("Href".to_string()),
8151                ..Default::default()
8152            },
8153        )
8154        .await
8155        .unwrap();
8156        // Straight into the column, bypassing `feed.rs` — the whole point.
8157        store::insert_entries(
8158            &state.db,
8159            feed,
8160            &[
8161                store::NewEntry {
8162                    guid: "hostile-1".to_string(),
8163                    url: Some("javascript:alert(1)".to_string()),
8164                    title: Some("Hostile entry".to_string()),
8165                    published: Some("2026-07-11T00:00:00Z".to_string()),
8166                    ..Default::default()
8167                },
8168                store::NewEntry {
8169                    guid: "benign-1".to_string(),
8170                    url: Some("https://href.example/post".to_string()),
8171                    title: Some("Benign entry".to_string()),
8172                    published: Some("2026-07-10T00:00:00Z".to_string()),
8173                    ..Default::default()
8174                },
8175            ],
8176            0,
8177        )
8178        .await
8179        .unwrap();
8180        store::replace_sub_refs(&state.db, did, &[feed])
8181            .await
8182            .unwrap();
8183        let rows = store::entries_for_feed(&state.db, did, feed).await.unwrap();
8184        let id_of = |guid: &str| {
8185            rows.iter()
8186                .find(|r| r.guid == guid)
8187                .unwrap_or_else(|| panic!("{guid} was not inserted"))
8188                .id
8189        };
8190
8191        let cookie = session_cookie(&state, did, None);
8192        let app = router(state.clone());
8193
8194        let render = |id: i64| {
8195            let app = app.clone();
8196            let cookie = cookie.clone();
8197            async move {
8198                let resp = app
8199                    .oneshot(
8200                        Request::builder()
8201                            .method("GET")
8202                            .uri(format!("/entries/{id}"))
8203                            .header(header::COOKIE, cookie)
8204                            .body(Body::empty())
8205                            .unwrap(),
8206                    )
8207                    .await
8208                    .unwrap();
8209                assert_eq!(resp.status(), StatusCode::OK);
8210                String::from_utf8(
8211                    axum::body::to_bytes(resp.into_body(), usize::MAX)
8212                        .await
8213                        .unwrap()
8214                        .to_vec(),
8215                )
8216                .unwrap()
8217            }
8218        };
8219
8220        let hostile = render(id_of("hostile-1")).await;
8221        // The reader page for THIS entry actually rendered. Without this the
8222        // three negatives below are satisfied by an empty body.
8223        assert!(
8224            hostile.contains("Hostile entry"),
8225            "the reader did not render the entry: {hostile}",
8226        );
8227        assert!(
8228            !hostile.to_ascii_lowercase().contains("javascript:"),
8229            "the hostile scheme reached the reader page: {hostile}",
8230        );
8231        // Not merely escaped — the template took its no-link branch. Both
8232        // `href`s are gated on the same `Option`, so this covers the byline
8233        // link and the action-bar button together.
8234        assert!(
8235            !hostile.contains("actionbar-open"),
8236            "the action bar rendered an open-original link for a refused URL: {hostile}",
8237        );
8238        assert!(
8239            !hostile.contains("Original \u{2197}"),
8240            "the byline rendered an original link for a refused URL: {hostile}",
8241        );
8242
8243        // The other direction: a legitimate entry still links out, so "render
8244        // nothing" cannot pass as a fix.
8245        let benign = render(id_of("benign-1")).await;
8246        assert!(
8247            benign.contains("Benign entry"),
8248            "the reader did not render the benign entry: {benign}",
8249        );
8250        // BOTH `href`s, counted. The negatives above fire on the action bar
8251        // first, so without this the byline needle `Original \u{2197}` is never
8252        // once observed failing — a misspelled needle would pass forever.
8253        assert_eq!(
8254            benign
8255                .matches(r#"href="https://href.example/post""#)
8256                .count(),
8257            2,
8258            "entry.html has two `href`s for the entry URL — the byline link and \
8259             the action-bar button — and this render produced a different \
8260             number: {benign}",
8261        );
8262        assert!(
8263            benign.contains("actionbar-open"),
8264            "a legitimate entry lost its open-original button: {benign}",
8265        );
8266        assert!(
8267            benign.contains("Original \u{2197}"),
8268            "a legitimate entry lost its byline link: {benign}",
8269        );
8270    }
8271
8272    /// **The outage fallback must not widen what the caller can READ — and the
8273    /// sibling test above can only see what it WRITES.**
8274    ///
8275    /// `pds_outage_does_not_widen_cross_did_access` asserts on `sub_ref` rows and
8276    /// on `entry_state`: the fallback's side effects. But the fail-open it names
8277    /// returns **early**, before `sync_sub_refs` runs, so it touches neither. It
8278    /// leaks through the list it *hands back* — the sidebar and the reader render
8279    /// from that list, so a caller sees another DID's feeds while `sub_ref` stays
8280    /// perfectly honest and every existing assertion stays green.
8281    ///
8282    /// Measured, not assumed: replacing `feeds_for_did(pool, did)` with
8283    /// `due_feeds(pool, "9999-…", 10_000)` over the whole shared cache — the
8284    /// exact historical bug the fallback's comment describes — left **all 663
8285    /// tests passing**. Cross-tenant isolation is the one property this project
8286    /// cannot regress quietly, and nothing observed it.
8287    ///
8288    /// So this asserts on the RETURN VALUE, which is the thing that reaches the
8289    /// user, and it deliberately does not look at `sub_ref` at all — that half is
8290    /// already covered above.
8291    #[tokio::test]
8292    async fn the_outage_fallback_returns_only_the_callers_own_feeds() {
8293        let did_a = "did:plc:aaaa";
8294        let state = test_state(&[]).await;
8295        store::grant_access(&state.db, did_a, None, "test", None)
8296            .await
8297            .unwrap();
8298
8299        let feed_a = store::upsert_feed(
8300            &state.db,
8301            &store::NewFeed {
8302                url: "https://a.example/feed.xml".to_string(),
8303                title: Some("A".to_string()),
8304                ..Default::default()
8305            },
8306        )
8307        .await
8308        .unwrap();
8309        let _feed_b = store::upsert_feed(
8310            &state.db,
8311            &store::NewFeed {
8312                url: "https://b.example/feed.xml".to_string(),
8313                title: Some("B".to_string()),
8314                ..Default::default()
8315            },
8316        )
8317        .await
8318        .unwrap();
8319        // A subscribes ONLY to feed_a. feed_b is in the shared cache and belongs
8320        // to nobody — exactly the row a whole-cache fallback would hand to A.
8321        store::replace_sub_refs(&state.db, did_a, &[feed_a])
8322            .await
8323            .unwrap();
8324
8325        // No sidecar and no PDS are reachable from a test, so
8326        // `list_subscriptions_sorted` fails and this IS the outage path. Assert
8327        // that, rather than assuming it: if the repo ever starts succeeding here,
8328        // this test would silently stop exercising the fallback at all.
8329        assert!(
8330            state.repo().list_subscriptions_sorted(did_a).await.is_err(),
8331            "this test is only meaningful on the outage path; the repo answered",
8332        );
8333
8334        let resolved = resolve_subscriptions(&state, did_a).await;
8335
8336        let urls: Vec<&str> = resolved.iter().map(|r| r.sub.url.as_str()).collect();
8337        assert_eq!(
8338            urls,
8339            vec!["https://a.example/feed.xml"],
8340            "the outage fallback must return the caller's OWN subscriptions only; \
8341             any other feed here is cross-tenant read access granted by an outage",
8342        );
8343    }
8344
8345    /// Build an [`AppState`] over a fresh in-memory DB with explicit feed caps,
8346    /// seeding `did` a beta seat + session-capable state.
8347    async fn test_state_with_caps(
8348        did: &str,
8349        max_subs_per_did: i64,
8350        max_feeds_global: i64,
8351    ) -> AppState {
8352        let db = store::init_url("sqlite::memory:").await.unwrap();
8353        let config = Config {
8354            cookie_secret: "test-cookie-secret-000".to_string(),
8355            beta_cap: 100,
8356            max_subs_per_did,
8357            max_feeds_global,
8358            ..Config::default()
8359        };
8360        store::grant_access(&db, did, None, "test", None)
8361            .await
8362            .unwrap();
8363        AppState::new(config, db).unwrap()
8364    }
8365
8366    /// An OPML document with `n` distinct public feeds.
8367    fn opml_with_feeds(n: usize) -> String {
8368        let mut outlines = String::new();
8369        for i in 0..n {
8370            outlines.push_str(&format!(
8371                "<outline type=\"rss\" text=\"F{i}\" xmlUrl=\"https://f{i}.example/feed.xml\"/>\n"
8372            ));
8373        }
8374        format!(
8375            "<?xml version=\"1.0\"?>\n<opml version=\"2.0\"><head><title>t</title></head><body>\n{outlines}</body></opml>"
8376        )
8377    }
8378
8379    /// OPML bulk import must honour the GLOBAL feeds ceiling: importing more
8380    /// distinct new feeds than the shared cache can hold caches only up to the
8381    /// ceiling — the rest are trimmed. (Regression: the import loop previously
8382    /// bypassed `max_feeds_global` entirely.)
8383    #[tokio::test]
8384    async fn opml_import_enforces_global_feeds_ceiling() {
8385        let did = "did:plc:importer";
8386        // Cap the shared cache at 3 feeds; import 10 distinct new ones.
8387        let state = test_state_with_caps(did, 0, 3).await;
8388        let cookie = session_cookie(&state, did, None);
8389        let (ct, body) = opml_multipart(opml_with_feeds(10).as_bytes());
8390        let app = router(state.clone());
8391
8392        let resp = app
8393            .oneshot(
8394                Request::builder()
8395                    .method("POST")
8396                    .uri("/opml")
8397                    .header(header::COOKIE, cookie)
8398                    .header("content-type", ct)
8399                    .body(Body::from(body))
8400                    .unwrap(),
8401            )
8402            .await
8403            .unwrap();
8404        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8405
8406        let feeds = store::count_feeds(&state.db).await.unwrap();
8407        assert!(
8408            feeds <= 3,
8409            "OPML import blew past the global ceiling: {feeds} feeds cached with cap=3"
8410        );
8411    }
8412
8413    /// **A malformed `at://` on the add path is "not a kind of feed we take",
8414    /// not "private/paid".** The first gate was the privacy classifier, whose
8415    /// at:// arm fails closed as `Private` for anything not a well-formed
8416    /// publication URI — so a typo (`did:plc:TOOSHORT`, a missing rkey) drew
8417    /// the private-feed flash and a "refused private/paid feed" log line. On
8418    /// main the same input reached `resolve_feed_url` and got "Couldn't find a
8419    /// feed". Storability is decided first for an at:// input, with its own
8420    /// message.
8421    #[tokio::test]
8422    async fn a_malformed_at_uri_on_the_add_path_is_refused_as_unsupported_not_private() {
8423        let did = "did:plc:typoist";
8424        let state = test_state_with_caps(did, 0, 0).await;
8425        let cookie = session_cookie(&state, did, None);
8426        for input in [
8427            "at%3A%2F%2Falice.example.com%2Fsite.standard.publication",
8428            "at%3A%2F%2Fdid%3Aplc%3ATOOSHORT%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
8429        ] {
8430            let resp = router(state.clone())
8431                .oneshot(
8432                    Request::builder()
8433                        .method("POST")
8434                        .uri("/subscriptions")
8435                        .header(header::COOKIE, cookie.clone())
8436                        .header("content-type", "application/x-www-form-urlencoded")
8437                        .body(Body::from(format!("url={input}")))
8438                        .unwrap(),
8439                )
8440                .await
8441                .unwrap();
8442            assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8443            let loc = resp
8444                .headers()
8445                .get(header::LOCATION)
8446                .unwrap()
8447                .to_str()
8448                .unwrap();
8449            assert!(
8450                loc.contains("kind%20of%20feed"),
8451                "expected the unsupported-feed flash for {input}, got {loc}"
8452            );
8453            assert!(
8454                !loc.contains("Private"),
8455                "a storability refusal was reported as a privacy one for {input}: {loc}"
8456            );
8457        }
8458        assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
8459    }
8460
8461    /// **An OPML entry this instance cannot store is counted and reported, not
8462    /// silently dropped.** The storability `continue` incremented nothing,
8463    /// while the privacy branch beside it produced a user-visible label — so
8464    /// an OPML exported from a standard.site-enabled instance imported
8465    /// "successfully" with entries missing and no reason given. The reader is
8466    /// told how many, and why.
8467    #[tokio::test]
8468    async fn opml_import_reports_entries_this_instance_cannot_store() {
8469        let did = "did:plc:renamer4";
8470        let (sidecar, _puts) = spawn_rename_sidecar(seeded_subscription()).await;
8471        let state = test_state_with_sidecar(&[did], &sidecar).await;
8472        assert!(!state.config.standard_site);
8473        let opml = format!(
8474            "<?xml version=\"1.0\"?>\n<opml version=\"2.0\"><head><title>t</title></head><body>\n\
8475             <outline type=\"rss\" text=\"Real\" xmlUrl=\"https://real.example/feed.xml\"/>\n\
8476             <outline type=\"rss\" text=\"Pub\" xmlUrl=\"{AT_URI_SUB}\"/>\n\
8477             </body></opml>"
8478        );
8479        let (ct, body) = opml_multipart(opml.as_bytes());
8480        let cookie = session_cookie(&state, did, None);
8481        let resp = router(state.clone())
8482            .oneshot(
8483                Request::builder()
8484                    .method("POST")
8485                    .uri("/opml")
8486                    .header(header::COOKIE, cookie)
8487                    .header("content-type", ct)
8488                    .body(Body::from(body))
8489                    .unwrap(),
8490            )
8491            .await
8492            .unwrap();
8493        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8494        let loc = resp
8495            .headers()
8496            .get(header::LOCATION)
8497            .unwrap()
8498            .to_str()
8499            .unwrap();
8500        assert!(
8501            loc.contains("Imported%201%20feed"),
8502            "unexpected flash: {loc}"
8503        );
8504        assert!(
8505            loc.contains("1%20feed%28s%29%20skipped") && loc.contains("can%20subscribe%20to"),
8506            "the dropped entry was not reported: {loc}"
8507        );
8508        // Reported by count only: the at-URI itself is not echoed back.
8509        assert!(
8510            !loc.contains("site.standard.publication"),
8511            "the URI was echoed: {loc}"
8512        );
8513    }
8514
8515    /// OPML bulk import must honour the PER-DID subscription cap: a DID at its
8516    /// cap imports zero new feeds.
8517    #[tokio::test]
8518    async fn opml_import_enforces_per_did_cap() {
8519        let did = "did:plc:capped";
8520        // Per-DID cap 2, global unlimited. Pre-seed the DID at its cap.
8521        let state = test_state_with_caps(did, 2, 0).await;
8522        let existing_a = store::upsert_feed(
8523            &state.db,
8524            &store::NewFeed {
8525                url: "https://have-a.example/feed.xml".to_string(),
8526                ..Default::default()
8527            },
8528        )
8529        .await
8530        .unwrap();
8531        let existing_b = store::upsert_feed(
8532            &state.db,
8533            &store::NewFeed {
8534                url: "https://have-b.example/feed.xml".to_string(),
8535                ..Default::default()
8536            },
8537        )
8538        .await
8539        .unwrap();
8540        store::replace_sub_refs(&state.db, did, &[existing_a, existing_b])
8541            .await
8542            .unwrap();
8543        let before = store::count_feeds(&state.db).await.unwrap();
8544
8545        let cookie = session_cookie(&state, did, None);
8546        let (ct, body) = opml_multipart(opml_with_feeds(10).as_bytes());
8547        let app = router(state.clone());
8548        let resp = app
8549            .oneshot(
8550                Request::builder()
8551                    .method("POST")
8552                    .uri("/opml")
8553                    .header(header::COOKIE, cookie)
8554                    .header("content-type", ct)
8555                    .body(Body::from(body))
8556                    .unwrap(),
8557            )
8558            .await
8559            .unwrap();
8560        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8561        // Headroom was 0 → no new feeds imported into the shared cache.
8562        let after = store::count_feeds(&state.db).await.unwrap();
8563        assert_eq!(after, before, "over-cap DID imported new feeds anyway");
8564    }
8565
8566    /// Single-add per-DID cap: a DID at its subscription cap is refused before
8567    /// any fetch, with the limit flash.
8568    #[tokio::test]
8569    async fn single_add_enforces_per_did_cap() {
8570        let did = "did:plc:subcapped";
8571        let state = test_state_with_caps(did, 1, 0).await;
8572        let f = store::upsert_feed(
8573            &state.db,
8574            &store::NewFeed {
8575                url: "https://have.example/feed.xml".to_string(),
8576                ..Default::default()
8577            },
8578        )
8579        .await
8580        .unwrap();
8581        store::replace_sub_refs(&state.db, did, &[f]).await.unwrap();
8582        let cookie = session_cookie(&state, did, None);
8583        let app = router(state.clone());
8584        let resp = app
8585            .oneshot(
8586                Request::builder()
8587                    .method("POST")
8588                    .uri("/subscriptions")
8589                    .header(header::COOKIE, cookie)
8590                    .header("content-type", "application/x-www-form-urlencoded")
8591                    .body(Body::from("url=https://another.example/feed.xml"))
8592                    .unwrap(),
8593            )
8594            .await
8595            .unwrap();
8596        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8597        let loc = resp
8598            .headers()
8599            .get(header::LOCATION)
8600            .unwrap()
8601            .to_str()
8602            .unwrap();
8603        assert!(
8604            loc.contains("Subscription%20limit%20reached"),
8605            "expected sub-limit flash, got {loc}"
8606        );
8607    }
8608
8609    /// `GET /` renders at most one page of rows and offers a way to the rest.
8610    ///
8611    /// The handler used to materialize EVERY unread entry — `SELECT e.*`, no
8612    /// `LIMIT`, article bodies included — and hand the lot to the template. With
8613    /// 250 entries that is the whole list in one response; with a real backlog on
8614    /// a 512 MB box it is the OOM the operator review flagged. Asserts the page
8615    /// is capped, the heading still reports the true total, and page 2 is
8616    /// reachable and disjoint.
8617    #[tokio::test]
8618    async fn the_reader_index_pages_instead_of_rendering_everything() {
8619        let did = "did:plc:pager";
8620        let state = test_state(&[]).await;
8621        store::grant_access(&state.db, did, None, "test", None)
8622            .await
8623            .unwrap();
8624        let feed = store::upsert_feed(
8625            &state.db,
8626            &store::NewFeed {
8627                url: "https://pager.example/feed.xml".to_string(),
8628                title: Some("Pager".to_string()),
8629                ..Default::default()
8630            },
8631        )
8632        .await
8633        .unwrap();
8634        let total = 250_usize;
8635        let entries: Vec<store::NewEntry> = (0..total)
8636            .map(|i| store::NewEntry {
8637                guid: format!("p-{i:04}"),
8638                url: Some(format!("https://pager.example/{i}")),
8639                title: Some(format!("Article {i:04}")),
8640                published: Some(format!("2026-07-{:02}T00:00:00Z", (i % 28) + 1)),
8641                content_html: Some("x".repeat(4_000)),
8642                ..Default::default()
8643            })
8644            .collect();
8645        store::insert_entries(&state.db, feed, &entries, 0)
8646            .await
8647            .unwrap();
8648        store::replace_sub_refs(&state.db, did, &[feed])
8649            .await
8650            .unwrap();
8651
8652        let cookie = session_cookie(&state, did, None);
8653        let app = router(state.clone());
8654        let get = |uri: &str| {
8655            let app = app.clone();
8656            let cookie = cookie.clone();
8657            let uri = uri.to_string();
8658            async move {
8659                let resp = app
8660                    .oneshot(
8661                        Request::builder()
8662                            .uri(uri)
8663                            .header(header::COOKIE, cookie)
8664                            .body(Body::empty())
8665                            .unwrap(),
8666                    )
8667                    .await
8668                    .unwrap();
8669                assert_eq!(resp.status(), StatusCode::OK);
8670                let bytes = axum::body::to_bytes(resp.into_body(), 8 * 1024 * 1024)
8671                    .await
8672                    .unwrap();
8673                String::from_utf8(bytes.to_vec()).unwrap()
8674            }
8675        };
8676
8677        let page1 = get("/").await;
8678        // One `<li class="entry…>` per rendered row. Counting "/entries/" would
8679        // over-count: each row carries several (the link plus the read/star
8680        // forms).
8681        let rows1 = page1.matches("<li class=\"entry").count();
8682        assert!(
8683            rows1 <= ENTRIES_PER_PAGE as usize,
8684            "page 1 rendered {rows1} entry links; the list is unbounded"
8685        );
8686        assert!(
8687            rows1 > 0,
8688            "page 1 rendered nothing at all: the page bound swallowed the list"
8689        );
8690        // The count is the TRUE total, not the page size — otherwise paging
8691        // would quietly relabel a 250-entry backlog as a 100-entry one.
8692        assert!(
8693            page1.contains("250 entries"),
8694            "heading must report the full total, not the page"
8695        );
8696        assert!(
8697            page1.contains("page=2"),
8698            "no way to reach the rest of the list: {}",
8699            &page1[..page1.len().min(400)]
8700        );
8701        // The body never belongs in a list response.
8702        assert!(
8703            !page1.contains(&"x".repeat(4_000)),
8704            "the list response carried an article body"
8705        );
8706
8707        let page2 = get("/?page=2").await;
8708        assert!(
8709            page2.matches("<li class=\"entry").count() > 0,
8710            "page 2 rendered no rows at all"
8711        );
8712        assert!(
8713            page2.contains("page=1") || page2.contains("Newer"),
8714            "page 2 offers no way back"
8715        );
8716        // Disjoint: an article on page 1 must not reappear on page 2.
8717        let first_title = (0..total)
8718            .map(|i| format!("Article {i:04}"))
8719            .find(|t| page1.contains(t))
8720            .expect("page 1 shows at least one titled article");
8721        assert!(
8722            !page2.contains(&first_title),
8723            "{first_title} appears on both pages"
8724        );
8725
8726        // A page past the end must not be a dead end. The empty state renders
8727        // instead of the pager, so an out-of-range page would leave a reader
8728        // with no link back — reachable by typing a number, and reachable
8729        // WITHOUT typing anything by paging to the end and then marking entries
8730        // read, which shrinks the list under the URL already in the address bar.
8731        let past_end = get("/?page=999").await;
8732        assert!(
8733            past_end.matches("<li class=\"entry").count() > 0,
8734            "an out-of-range page rendered nothing and offered no way back"
8735        );
8736        assert!(
8737            past_end.contains("page=2"),
8738            "the clamped page offers no pager"
8739        );
8740    }
8741
8742    /// Reader-view mark-read (a request tagged `X-FR-Reader: 1`) must return the
8743    /// out-of-band action-bar fragment with FRESHLY re-read state so a second
8744    /// keypress reverses the toggle: `hx-swap-oob="outerHTML"` is present, and
8745    /// the hidden `read` input + `aria-pressed` reflect the NEW state. The list
8746    /// view (no reader header) instead swaps the row. This guards the reader OOB
8747    /// toggle wiring, which had no test.
8748    #[tokio::test]
8749    async fn reader_mark_read_returns_oob_actionbar_with_flipped_state() {
8750        let did = "did:plc:reader";
8751        let state = test_state(&[]).await;
8752        store::grant_access(&state.db, did, None, "test", None)
8753            .await
8754            .unwrap();
8755        let feed = store::upsert_feed(
8756            &state.db,
8757            &store::NewFeed {
8758                url: "https://reader.example/feed.xml".to_string(),
8759                title: Some("Reader".to_string()),
8760                ..Default::default()
8761            },
8762        )
8763        .await
8764        .unwrap();
8765        store::insert_entries(
8766            &state.db,
8767            feed,
8768            &[store::NewEntry {
8769                guid: "r-1".to_string(),
8770                url: Some("https://reader.example/1".to_string()),
8771                title: Some("Article".to_string()),
8772                published: Some("2026-07-11T00:00:00Z".to_string()),
8773                content_html: Some("<p>body</p>".to_string()),
8774                ..Default::default()
8775            }],
8776            0,
8777        )
8778        .await
8779        .unwrap();
8780        store::replace_sub_refs(&state.db, did, &[feed])
8781            .await
8782            .unwrap();
8783        let entry_id = store::entries_for_feed(&state.db, did, feed).await.unwrap()[0].id;
8784
8785        let cookie = session_cookie(&state, did, None);
8786        let app = router(state.clone());
8787
8788        // Reader-tagged mark-read → OOB action-bar fragment, entry now READ.
8789        let resp = app
8790            .clone()
8791            .oneshot(
8792                Request::builder()
8793                    .method("POST")
8794                    .uri(format!("/entries/{entry_id}/read"))
8795                    .header(header::COOKIE, cookie.clone())
8796                    .header("HX-Request", "true")
8797                    .header("X-FR-Reader", "1")
8798                    .header("content-type", "application/x-www-form-urlencoded")
8799                    .body(Body::from("read=true"))
8800                    .unwrap(),
8801            )
8802            .await
8803            .unwrap();
8804        assert_eq!(resp.status(), StatusCode::OK);
8805        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
8806            .await
8807            .unwrap();
8808        let html = String::from_utf8(bytes.to_vec()).unwrap();
8809        assert!(
8810            html.contains("hx-swap-oob=\"outerHTML\""),
8811            "reader response must be an OOB swap: {html}"
8812        );
8813        assert!(
8814            html.contains(r#"id="entry-actionbar""#),
8815            "reader response must be the action-bar fragment: {html}"
8816        );
8817        // Now READ: the read button reflects it (aria-pressed=true) and the
8818        // hidden value flips to `false` so the next tap marks it UNREAD.
8819        assert!(
8820            html.contains(r#"aria-pressed="true""#),
8821            "read button must show pressed after marking read: {html}"
8822        );
8823        assert!(
8824            html.contains(r#"name="read" value="false""#),
8825            "hidden read value must flip to false so a second tap reverses: {html}"
8826        );
8827
8828        // A second reader mark-read (submitting the flipped `read=false`) marks
8829        // it UNREAD again — the toggle reverses.
8830        let resp2 = app
8831            .oneshot(
8832                Request::builder()
8833                    .method("POST")
8834                    .uri(format!("/entries/{entry_id}/read"))
8835                    .header(header::COOKIE, cookie)
8836                    .header("HX-Request", "true")
8837                    .header("X-FR-Reader", "1")
8838                    .header("content-type", "application/x-www-form-urlencoded")
8839                    .body(Body::from("read=false"))
8840                    .unwrap(),
8841            )
8842            .await
8843            .unwrap();
8844        assert_eq!(resp2.status(), StatusCode::OK);
8845        let bytes2 = axum::body::to_bytes(resp2.into_body(), 64 * 1024)
8846            .await
8847            .unwrap();
8848        let html2 = String::from_utf8(bytes2.to_vec()).unwrap();
8849        assert!(
8850            html2.contains(r#"aria-pressed="false""#),
8851            "read button must show un-pressed after reversing: {html2}"
8852        );
8853        assert!(
8854            html2.contains(r#"name="read" value="true""#),
8855            "hidden read value must flip back to true: {html2}"
8856        );
8857    }
8858
8859    /// The LIST view (no `X-FR-Reader` header) swaps the row, not the OOB
8860    /// action-bar — the counterpart to the reader-OOB test above.
8861    #[tokio::test]
8862    async fn list_mark_read_returns_row_not_oob_actionbar() {
8863        let did = "did:plc:listv";
8864        let state = test_state(&[]).await;
8865        store::grant_access(&state.db, did, None, "test", None)
8866            .await
8867            .unwrap();
8868        let feed = store::upsert_feed(
8869            &state.db,
8870            &store::NewFeed {
8871                url: "https://list.example/feed.xml".to_string(),
8872                title: Some("List".to_string()),
8873                ..Default::default()
8874            },
8875        )
8876        .await
8877        .unwrap();
8878        store::insert_entries(
8879            &state.db,
8880            feed,
8881            &[store::NewEntry {
8882                guid: "l-1".to_string(),
8883                url: Some("https://list.example/1".to_string()),
8884                title: Some("Article".to_string()),
8885                published: Some("2026-07-11T00:00:00Z".to_string()),
8886                ..Default::default()
8887            }],
8888            0,
8889        )
8890        .await
8891        .unwrap();
8892        store::replace_sub_refs(&state.db, did, &[feed])
8893            .await
8894            .unwrap();
8895        let entry_id = store::entries_for_feed(&state.db, did, feed).await.unwrap()[0].id;
8896
8897        let cookie = session_cookie(&state, did, None);
8898        let app = router(state.clone());
8899
8900        let resp = app
8901            .oneshot(
8902                Request::builder()
8903                    .method("POST")
8904                    .uri(format!("/entries/{entry_id}/read"))
8905                    .header(header::COOKIE, cookie)
8906                    .header("HX-Request", "true")
8907                    .header("content-type", "application/x-www-form-urlencoded")
8908                    .body(Body::from("read=true"))
8909                    .unwrap(),
8910            )
8911            .await
8912            .unwrap();
8913        assert_eq!(resp.status(), StatusCode::OK);
8914        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
8915            .await
8916            .unwrap();
8917        let html = String::from_utf8(bytes.to_vec()).unwrap();
8918        assert!(
8919            !html.contains("hx-swap-oob"),
8920            "list-view response must NOT be an OOB swap: {html}"
8921        );
8922        // **And it must actually BE the row.** The assertion above is satisfied
8923        // by an empty body, or by any response that simply omits the attribute —
8924        // so on its own it pins half a property and the name promises the other
8925        // half.
8926        assert!(
8927            html.contains(&format!("/entries/{entry_id}")),
8928            "the response is not the row for this entry: {html}",
8929        );
8930        assert!(
8931            html.contains("Article"),
8932            "the row rendered without its title: {html}",
8933        );
8934        // **The row comes back carrying read state. That is all this proves.**
8935        //
8936        // It does NOT prove the state was persisted: the handler renders
8937        // `Some(read)` from the form value, so making `mark_read` roll back
8938        // instead of commit fails 11 store tests and leaves this one green.
8939        //
8940        // It does not prove the OVERRIDE either, which an earlier version of
8941        // this comment claimed. Verified: changing the call site to
8942        // `build_entry_row(pool, &did, id, None)` — deleting the override
8943        // wholesale — keeps the whole suite green, because `mark_read` has
8944        // already persisted the same value two lines earlier, so reading it back
8945        // from the database produces an identical row.
8946        //
8947        // Distinguishing the two needs a case where the override and the stored
8948        // state DISAGREE, which this handler never produces: it writes the value
8949        // it then renders. Left as a known gap rather than described as covered.
8950        assert!(
8951            html.contains("is-read"),
8952            "the row came back without the read state it was just given: {html}",
8953        );
8954    }
8955
8956    // -----------------------------------------------------------------------
8957    // Rename parity (POST /subscriptions/{rkey}/rename)
8958    // -----------------------------------------------------------------------
8959
8960    /// **Autodiscovery cannot smuggle a non-http(s) URL into storage.**
8961    ///
8962    /// The add path gates the URL the user *typed*; the URL it *stores* is
8963    /// whatever `resolve_feed_url` returns, which for an HTML page is a
8964    /// publisher-controlled `<link rel="alternate">` href. Two layers stop
8965    /// that: `discover_feed` yields only http(s), and the add path re-checks
8966    /// storability on the resolved URL. This test pins the DISJUNCTION —
8967    /// each layer alone holds it, both removed fails it — driven through the
8968    /// real route against a real local server.
8969    ///
8970    /// **Why the fixture is `ftp://`, not `at://`.** This began as the
8971    /// at-URI bypass test from #164, and it was vacuous twice over. Handle
8972    /// form: once storage became DID-only the privacy classifier refused it
8973    /// at its own gate. DID form: `Url::parse` cannot read it (invalid port
8974    /// — the colons in the DID), so `discover_feed` drops it before either
8975    /// layer exists. An at:// link cannot come out of autodiscovery under
8976    /// ANY mutation of the layers, so no test through this route can pin
8977    /// them with one. `ftp://` reaches both. The at:// case is guaranteed by
8978    /// structure and pinned where it lives: `discover_skips_a_non_http_
8979    /// alternate` and the storability tests in `feed.rs`.
8980    #[tokio::test]
8981    async fn autodiscovery_cannot_smuggle_a_non_http_url_into_storage() {
8982        let did = "did:plc:autodiscovered";
8983        // Access granted, both caps disabled — the only gates left are the
8984        // two under test.
8985        let state = test_state_with_caps(did, 0, 0).await;
8986
8987        let page = r#"<!doctype html><html><head><title>Blog</title>
8988            <link rel="alternate" type="application/rss+xml" href="ftp://files.example/feed.xml">
8989            </head><body>hi</body></html>"#;
8990        let base = crate::net::tests::serve_body(page.as_bytes().to_vec()).await;
8991        let port: u16 = base
8992            .trim_end_matches('/')
8993            .rsplit(':')
8994            .next()
8995            .unwrap()
8996            .parse()
8997            .unwrap();
8998        crate::net::test_host_override(
8999            "autodiscover-ftp.test",
9000            std::net::SocketAddr::from(([127, 0, 0, 1], port)),
9001        );
9002
9003        let cookie = session_cookie(&state, did, None);
9004        let resp = router(state.clone())
9005            .oneshot(
9006                Request::builder()
9007                    .method("POST")
9008                    .uri("/subscriptions")
9009                    .header(header::COOKIE, cookie)
9010                    .header("content-type", "application/x-www-form-urlencoded")
9011                    .body(Body::from(format!(
9012                        "url=http://autodiscover-ftp.test:{port}/"
9013                    )))
9014                    .unwrap(),
9015            )
9016            .await
9017            .unwrap();
9018        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9019        let loc = resp
9020            .headers()
9021            .get(header::LOCATION)
9022            .unwrap()
9023            .to_str()
9024            .unwrap();
9025        assert_ne!(loc, "/login", "the test never reached the add path");
9026        assert_ne!(loc, "/", "the subscribe succeeded");
9027
9028        assert_eq!(
9029            store::count_feeds(&state.db).await.unwrap(),
9030            0,
9031            "a non-http(s) URL from autodiscovery was stored"
9032        );
9033        assert_eq!(
9034            store::count_subscriptions_for_did(&state.db, did)
9035                .await
9036                .unwrap(),
9037            0
9038        );
9039    }
9040
9041    /// A rename that points at a BRAND-NEW feed URL while the shared cache is at
9042    /// its global ceiling must be refused (capacity flash) and must NOT insert a
9043    /// new `feeds` row — parity with add_subscription's global-cap guard, so a
9044    /// rename loop can't inflate the shared cache past the cap.
9045    #[tokio::test]
9046    async fn rename_to_new_url_refused_at_global_feeds_cap() {
9047        let did = "did:plc:renamer4";
9048        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
9049        // Global cap 1; pre-fill it with one feed so headroom is 0.
9050        let state = test_state_with_sidecar_and(&[did], &sidecar, false, 1).await;
9051        store::upsert_feed(
9052            &state.db,
9053            &store::NewFeed {
9054                url: "https://existing.example/feed.xml".to_string(),
9055                ..Default::default()
9056            },
9057        )
9058        .await
9059        .unwrap();
9060        let before = store::count_feeds(&state.db).await.unwrap();
9061        assert_eq!(before, 1);
9062
9063        let cookie = session_cookie(&state, did, None);
9064        let resp = router(state.clone())
9065            .oneshot(
9066                Request::builder()
9067                    .method("POST")
9068                    .uri("/subscriptions/rk-keep/rename")
9069                    .header(header::COOKIE, cookie)
9070                    .header("content-type", "application/x-www-form-urlencoded")
9071                    // A URL not in the cache → would be a NEW feeds row.
9072                    .body(Body::from(
9073                        "url=https://brand-new.example/feed.xml&title=Renamed",
9074                    ))
9075                    .unwrap(),
9076            )
9077            .await
9078            .unwrap();
9079        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9080        let loc = resp
9081            .headers()
9082            .get(header::LOCATION)
9083            .unwrap()
9084            .to_str()
9085            .unwrap();
9086        assert!(
9087            loc.contains("feed%20capacity"),
9088            "expected the feed-capacity flash, got {loc}"
9089        );
9090        // No new feeds row was inserted, and nothing reached the PDS.
9091        assert_eq!(store::count_feeds(&state.db).await.unwrap(), before);
9092        assert!(
9093            puts.lock().unwrap().is_empty(),
9094            "a refused repoint reached the PDS"
9095        );
9096    }
9097
9098    /// A repoint to an EXISTING URL adds no row, so it is allowed even at the
9099    /// global cap (only new URLs are gated) — the other half of the guard.
9100    ///
9101    /// On the sidecar fake, so "allowed" means the put actually happened: the
9102    /// earlier harness had no sidecar, and this passed on a "could not reach
9103    /// your PDS" flash that merely was not the capacity one.
9104    #[tokio::test]
9105    async fn rename_to_existing_url_allowed_at_global_feeds_cap() {
9106        let did = "did:plc:renamer4";
9107        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
9108        let state = test_state_with_sidecar_and(&[did], &sidecar, false, 1).await;
9109        store::upsert_feed(
9110            &state.db,
9111            &store::NewFeed {
9112                url: "https://existing.example/feed.xml".to_string(),
9113                ..Default::default()
9114            },
9115        )
9116        .await
9117        .unwrap();
9118        let before = store::count_feeds(&state.db).await.unwrap();
9119
9120        let cookie = session_cookie(&state, did, None);
9121        let resp = router(state.clone())
9122            .oneshot(
9123                Request::builder()
9124                    .method("POST")
9125                    .uri("/subscriptions/rk-keep/rename")
9126                    .header(header::COOKIE, cookie)
9127                    .header("content-type", "application/x-www-form-urlencoded")
9128                    .body(Body::from(
9129                        "url=https://existing.example/feed.xml&title=Retitled",
9130                    ))
9131                    .unwrap(),
9132            )
9133            .await
9134            .unwrap();
9135        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9136        let loc = resp
9137            .headers()
9138            .get(header::LOCATION)
9139            .unwrap()
9140            .to_str()
9141            .unwrap();
9142        assert_eq!(loc, "/", "the repoint to a cached URL was refused: {loc}");
9143        assert_eq!(
9144            puts.lock().unwrap().len(),
9145            1,
9146            "the repoint did not reach the PDS"
9147        );
9148        assert_eq!(store::count_feeds(&state.db).await.unwrap(), before);
9149    }
9150
9151    /// A rename with a blank URL writes nothing anywhere.
9152    #[tokio::test]
9153    async fn rename_with_blank_url_writes_nothing() {
9154        let did = "did:plc:renamer3";
9155        let state = test_state_with_caps(did, 0, 0).await;
9156        let before = store::count_feeds(&state.db).await.unwrap();
9157        assert_eq!(before, 0);
9158
9159        let cookie = session_cookie(&state, did, None);
9160        let app = router(state.clone());
9161        let resp = app
9162            .oneshot(
9163                Request::builder()
9164                    .method("POST")
9165                    .uri("/subscriptions/rkey123/rename")
9166                    .header(header::COOKIE, cookie)
9167                    .header("content-type", "application/x-www-form-urlencoded")
9168                    // Whitespace-only URL trims to empty.
9169                    .body(Body::from("url=%20%20&title=Nope"))
9170                    .unwrap(),
9171            )
9172            .await
9173            .unwrap();
9174        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9175        assert_eq!(
9176            resp.headers()
9177                .get(header::LOCATION)
9178                .unwrap()
9179                .to_str()
9180                .unwrap(),
9181            "/",
9182        );
9183        // Nothing was cached.
9184        assert_eq!(
9185            store::count_feeds(&state.db).await.unwrap(),
9186            0,
9187            "blank-URL rename wrote a junk feeds row"
9188        );
9189    }
9190
9191    /// A sidecar mock that serves ONE existing subscription record and captures
9192    /// every `put` body a rename produces.
9193    ///
9194    /// **Reads to `content-length` rather than taking one `read`.** A single
9195    /// read gets whatever one segment carried; if the head and body land
9196    /// separately the capture holds no record and every field assertion below
9197    /// passes for the wrong reason. Each captured body must also mention the
9198    /// collection, so an empty capture fails loudly instead of quietly.
9199    async fn spawn_rename_sidecar(
9200        existing: serde_json::Value,
9201    ) -> (String, std::sync::Arc<std::sync::Mutex<Vec<String>>>) {
9202        use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
9203        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9204        let addr = listener.local_addr().unwrap();
9205        let puts = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
9206        let sink = puts.clone();
9207        tokio::spawn(async move {
9208            loop {
9209                let Ok((mut sock, _)) = listener.accept().await else {
9210                    break;
9211                };
9212                let mut raw: Vec<u8> = Vec::new();
9213                let mut chunk = [0u8; 4096];
9214                let body_text = loop {
9215                    let Ok(n) = sock.read(&mut chunk).await else {
9216                        break String::new();
9217                    };
9218                    if n == 0 {
9219                        break String::from_utf8_lossy(&raw).to_string();
9220                    }
9221                    raw.extend_from_slice(&chunk[..n]);
9222                    let Some(split) = raw.windows(4).position(|w| w == b"\r\n\r\n") else {
9223                        continue;
9224                    };
9225                    let (head, body) = raw.split_at(split + 4);
9226                    let want = String::from_utf8_lossy(head).lines().find_map(|l| {
9227                        let (k, v) = l.split_once(':')?;
9228                        k.eq_ignore_ascii_case("content-length")
9229                            .then(|| v.trim().parse::<usize>().ok())?
9230                    });
9231                    if want.is_none_or(|want| body.len() >= want) {
9232                        break String::from_utf8_lossy(body).to_string();
9233                    }
9234                };
9235
9236                // `"action":"put"` is the rename write; anything else is the read.
9237                let is_put = body_text.contains("\"action\":\"put\"");
9238                let data = if is_put {
9239                    sink.lock().unwrap().push(body_text.clone());
9240                    serde_json::json!({
9241                        "uri": "at://did:plc:x/community.lexicon.rss.subscription/rk-keep",
9242                        "cid": "bafyreiafter"
9243                    })
9244                } else {
9245                    serde_json::json!({ "records": [existing.clone()] })
9246                };
9247                let body = serde_json::json!({ "ok": true, "data": data }).to_string();
9248                let resp = format!(
9249                    "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
9250                    body.len(),
9251                    body
9252                );
9253                let _ = sock.write_all(resp.as_bytes()).await;
9254                let _ = sock.flush().await;
9255            }
9256        });
9257        (format!("http://{addr}"), puts)
9258    }
9259
9260    /// The existing record a rename must not destroy.
9261    fn seeded_subscription() -> serde_json::Value {
9262        serde_json::json!({
9263            "uri": "at://did:plc:renamer4/community.lexicon.rss.subscription/rk-keep",
9264            "cid": "bafyreibefore",
9265            "value": {
9266                "$type": "community.lexicon.rss.subscription",
9267                "url": "https://example.com/feed.xml",
9268                "title": "Old title",
9269                "siteUrl": "https://example.com/blog",
9270                "fetchHint": "hourly",
9271                "private": false,
9272                "createdAt": "2024-03-01T00:00:00.000Z"
9273            }
9274        })
9275    }
9276
9277    /// An existing standard.site subscription, as the 19 in production are:
9278    /// written before this reader refused the scheme, still in the repo.
9279    fn seeded_at_uri_subscription() -> serde_json::Value {
9280        seeded_subscription_with_url(AT_URI_SUB)
9281    }
9282    /// An existing subscription record at `rk-keep` with the given URL.
9283    fn seeded_subscription_with_url(url: &str) -> serde_json::Value {
9284        serde_json::json!({
9285            "uri": "at://did:plc:renamer5/community.lexicon.rss.subscription/rk-keep",
9286            "cid": "bafyreibefore",
9287            "value": {
9288                "$type": "community.lexicon.rss.subscription",
9289                "url": url,
9290                "title": "Old title",
9291                "private": false,
9292                "createdAt": "2024-03-01T00:00:00.000Z"
9293            }
9294        })
9295    }
9296    const AT_URI_SUB: &str =
9297        "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lab2c4d5e6f7g8h";
9298    const AT_URI_SUB_ENC: &str =
9299        "at%3A%2F%2Fdid%3Aplc%3Aohutz6x5acjmpuulp3x7wxxc%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h";
9300
9301    /// **Retitling an existing `at://` subscription must work with the flag off.**
9302    ///
9303    /// The storability guard was placed before the repo lookup, so it refused
9304    /// any rename whose URL is an at-URI — including a pure title or folder
9305    /// change on a record that already exists. On main that rename succeeded;
9306    /// the 19 production records would have become un-editable. The flag gates
9307    /// what may be STORED in the cache, not whether a reader may edit their own
9308    /// record: the PDS write goes through, the cache row is simply not created.
9309    #[tokio::test]
9310    async fn retitling_an_existing_at_uri_subscription_survives_the_flag_being_off() {
9311        let did = "did:plc:renamer5";
9312        let (sidecar, puts) = spawn_rename_sidecar(seeded_at_uri_subscription()).await;
9313        let state = test_state_with_sidecar(&[did], &sidecar).await;
9314        assert!(
9315            !state.config.standard_site,
9316            "the flag must be off for this test"
9317        );
9318        let cookie = session_cookie(&state, did, None);
9319        let resp = router(state.clone())
9320            .oneshot(
9321                Request::builder()
9322                    .method("POST")
9323                    .uri("/subscriptions/rk-keep/rename")
9324                    .header(header::COOKIE, cookie)
9325                    .header("content-type", "application/x-www-form-urlencoded")
9326                    .body(Body::from(format!("url={AT_URI_SUB_ENC}&title=New+title")))
9327                    .unwrap(),
9328            )
9329            .await
9330            .unwrap();
9331        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9332        let loc = resp
9333            .headers()
9334            .get(header::LOCATION)
9335            .unwrap()
9336            .to_str()
9337            .unwrap();
9338        assert_eq!(loc, "/", "the retitle was refused: {loc}");
9339
9340        let bodies = puts.lock().unwrap().clone();
9341        assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
9342        let sent: serde_json::Value = serde_json::from_str(&bodies[0]).expect("put body is JSON");
9343        assert_eq!(
9344            sent["record"]["title"], "New title",
9345            "the rename did not apply"
9346        );
9347        assert_eq!(
9348            sent["record"]["url"], AT_URI_SUB,
9349            "the rename changed the URL"
9350        );
9351
9352        // The flag still means what it says for the CACHE: no at:// row.
9353        let cached: i64 = store::count_unpollable_feeds(&state.db).await.unwrap();
9354        assert_eq!(cached, 0, "a retitle stored an at:// row with the flag off");
9355    }
9356
9357    /// **Repointing a subscription AT an `at://` URI is still refused with the
9358    /// flag off** — the half of the guard that has to survive the fix above.
9359    /// Nothing reaches the PDS and nothing reaches the cache.
9360    #[tokio::test]
9361    async fn repointing_a_subscription_at_an_at_uri_is_refused_with_the_flag_off() {
9362        let did = "did:plc:renamer4";
9363        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
9364        let state = test_state_with_sidecar(&[did], &sidecar).await;
9365        let cookie = session_cookie(&state, did, None);
9366        let resp = router(state.clone())
9367            .oneshot(
9368                Request::builder()
9369                    .method("POST")
9370                    .uri("/subscriptions/rk-keep/rename")
9371                    .header(header::COOKIE, cookie)
9372                    .header("content-type", "application/x-www-form-urlencoded")
9373                    .body(Body::from(format!("url={AT_URI_SUB_ENC}&title=Moved")))
9374                    .unwrap(),
9375            )
9376            .await
9377            .unwrap();
9378        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9379        let loc = resp
9380            .headers()
9381            .get(header::LOCATION)
9382            .unwrap()
9383            .to_str()
9384            .unwrap();
9385        assert!(loc.contains("flash="), "the repoint was not refused: {loc}");
9386        assert!(
9387            !loc.contains("Private"),
9388            "a storability refusal was reported as a privacy one: {loc}"
9389        );
9390        assert!(
9391            puts.lock().unwrap().is_empty(),
9392            "the repoint reached the PDS"
9393        );
9394        let cached: i64 = store::count_unpollable_feeds(&state.db).await.unwrap();
9395        assert_eq!(cached, 0);
9396    }
9397
9398    /// Posts a retitle of `rk-keep` with its URL unchanged; returns the
9399    /// redirect location.
9400    async fn retitle_unchanged(state: &AppState, did: &str, url_enc: &str) -> String {
9401        let cookie = session_cookie(state, did, None);
9402        let resp = router(state.clone())
9403            .oneshot(
9404                Request::builder()
9405                    .method("POST")
9406                    .uri("/subscriptions/rk-keep/rename")
9407                    .header(header::COOKIE, cookie)
9408                    .header("content-type", "application/x-www-form-urlencoded")
9409                    .body(Body::from(format!("url={url_enc}&title=New+title")))
9410                    .unwrap(),
9411            )
9412            .await
9413            .unwrap();
9414        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9415        resp.headers()
9416            .get(header::LOCATION)
9417            .unwrap()
9418            .to_str()
9419            .unwrap()
9420            .to_string()
9421    }
9422
9423    /// **The privacy gate has the same ordering bug the storable gate had.**
9424    ///
9425    /// Another client can write a subscription whose URL is an at-URI that is
9426    /// not a well-formed publication URI at all — a feed generator, say. On
9427    /// main a retitle of it succeeded (the DID form fails `Url::parse`, which
9428    /// the classifier reads as `Public`). The narrowed at:// arm now fails
9429    /// closed as `Private` for it, and the gate ran before `url_changed` was
9430    /// known — so the record became un-editable, with a flash claiming it "was
9431    /// not saved or sent anywhere". Both gates now apply to a repoint only.
9432    #[tokio::test]
9433    async fn retitling_an_existing_at_uri_record_that_is_not_a_publication_survives() {
9434        let did = "did:plc:renamer5";
9435        let other = "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/app.bsky.feed.generator/whats-hot";
9436        let other_enc =
9437            "at%3A%2F%2Fdid%3Aplc%3Aohutz6x5acjmpuulp3x7wxxc%2Fapp.bsky.feed.generator%2Fwhats-hot";
9438        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription_with_url(other)).await;
9439        let state = test_state_with_sidecar(&[did], &sidecar).await;
9440        let loc = retitle_unchanged(&state, did, other_enc).await;
9441        assert_eq!(loc, "/", "the retitle was refused: {loc}");
9442        let bodies = puts.lock().unwrap().clone();
9443        assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
9444        let sent: serde_json::Value = serde_json::from_str(&bodies[0]).unwrap();
9445        assert_eq!(sent["record"]["title"], "New title");
9446        assert_eq!(sent["record"]["url"], other);
9447    }
9448
9449    /// **A repoint to a secret-bearing URL is still refused** — the half of
9450    /// the privacy gate that has to survive moving it behind `url_changed`.
9451    /// Found by mutation: with the gate deleted outright, nothing failed.
9452    #[tokio::test]
9453    async fn repointing_a_subscription_at_a_private_feed_is_refused() {
9454        let did = "did:plc:renamer4";
9455        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
9456        let state = test_state_with_sidecar(&[did], &sidecar).await;
9457        let cookie = session_cookie(&state, did, None);
9458        let resp = router(state.clone())
9459            .oneshot(
9460                Request::builder()
9461                    .method("POST")
9462                    .uri("/subscriptions/rk-keep/rename")
9463                    .header(header::COOKIE, cookie)
9464                    .header("content-type", "application/x-www-form-urlencoded")
9465                    .body(Body::from(
9466                        "url=https%3A%2F%2Fpaid.example%2Ffeed.xml%3Ftoken%3DZm9vYmFyc2VjcmV0dG9rZW4&title=Moved",
9467                    ))
9468                    .unwrap(),
9469            )
9470            .await
9471            .unwrap();
9472        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9473        let loc = resp
9474            .headers()
9475            .get(header::LOCATION)
9476            .unwrap()
9477            .to_str()
9478            .unwrap();
9479        assert!(
9480            loc.contains("Private"),
9481            "the private repoint was not refused: {loc}"
9482        );
9483        assert!(
9484            puts.lock().unwrap().is_empty(),
9485            "a secret-bearing URL reached the PDS"
9486        );
9487        // The repo's fixture token: opaque enough for the classifier, not a real
9488        // key shape (a Stripe-shaped fixture tripped the secret scanner — rightly).
9489        let leaked = "https://paid.example/feed.xml?token=Zm9vYmFyc2VjcmV0dG9rZW4";
9490        assert!(store::get_feed_by_url(&state.db, leaked)
9491            .await
9492            .unwrap()
9493            .is_none());
9494    }
9495
9496    /// **A retitle of a never-cached at:// subscription is not "at feed
9497    /// capacity".** The global-ceiling check keyed on "URL not in the cache",
9498    /// and an at:// record is never cached with the flag off — so at capacity,
9499    /// a pure retitle was refused for a row the handler would not insert. The
9500    /// check now runs once `url_changed` is known and only for a repoint.
9501    #[tokio::test]
9502    async fn retitling_an_uncached_at_uri_subscription_is_not_refused_at_feed_capacity() {
9503        let did = "did:plc:renamer5";
9504        let (sidecar, puts) = spawn_rename_sidecar(seeded_at_uri_subscription()).await;
9505        // Ceiling 1, and one real feed already fills it.
9506        let state = test_state_with_sidecar_and(&[did], &sidecar, false, 1).await;
9507        store::upsert_feed(
9508            &state.db,
9509            &store::NewFeed {
9510                url: "https://filler.example/feed.xml".to_string(),
9511                ..Default::default()
9512            },
9513        )
9514        .await
9515        .unwrap();
9516        let loc = retitle_unchanged(&state, did, AT_URI_SUB_ENC).await;
9517        assert_eq!(loc, "/", "the retitle was refused: {loc}");
9518        assert_eq!(
9519            puts.lock().unwrap().len(),
9520            1,
9521            "the retitle did not reach the PDS"
9522        );
9523        assert_eq!(
9524            store::count_feeds(&state.db).await.unwrap(),
9525            1,
9526            "a row was inserted"
9527        );
9528    }
9529
9530    /// **With the flag ON, a well-formed at:// paste is still refused as
9531    /// unsupported** — not "Couldn't find a feed" plus a `warn!`. Nothing can
9532    /// fetch `at://` until the reader is wired, whatever the flag says, and the
9533    /// docs promise this answer "with the flag on or off". This is also the
9534    /// suite's first state with the flag on: every other site passes the flag
9535    /// through with `false`, where a literal `false` would be indistinguishable.
9536    #[tokio::test]
9537    async fn a_well_formed_at_uri_paste_is_refused_as_unsupported_with_the_flag_on() {
9538        let did = "did:plc:renamer5";
9539        let (sidecar, _puts) = spawn_rename_sidecar(seeded_subscription()).await;
9540        let state = test_state_with_sidecar_and(&[did], &sidecar, true, 0).await;
9541        let cookie = session_cookie(&state, did, None);
9542        let resp = router(state.clone())
9543            .oneshot(
9544                Request::builder()
9545                    .method("POST")
9546                    .uri("/subscriptions")
9547                    .header(header::COOKIE, cookie)
9548                    .header("content-type", "application/x-www-form-urlencoded")
9549                    .body(Body::from(format!("url={AT_URI_SUB_ENC}")))
9550                    .unwrap(),
9551            )
9552            .await
9553            .unwrap();
9554        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9555        let loc = resp
9556            .headers()
9557            .get(header::LOCATION)
9558            .unwrap()
9559            .to_str()
9560            .unwrap();
9561        assert!(
9562            loc.contains("kind%20of%20feed"),
9563            "expected the unsupported flash: {loc}"
9564        );
9565        assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
9566    }
9567
9568    /// **With the flag ON, an OPML at:// entry is stored.** The one storage
9569    /// path that is meant to work today, asserted with the flag actually on.
9570    #[tokio::test]
9571    async fn opml_import_stores_an_at_uri_entry_with_the_flag_on() {
9572        let did = "did:plc:renamer5";
9573        let (sidecar, _puts) = spawn_rename_sidecar(seeded_subscription()).await;
9574        let state = test_state_with_sidecar_and(&[did], &sidecar, true, 0).await;
9575        let opml = format!(
9576            "<?xml version=\"1.0\"?>\n<opml version=\"2.0\"><head><title>t</title></head><body>\n\
9577             <outline type=\"rss\" text=\"Real\" xmlUrl=\"https://real.example/feed.xml\"/>\n\
9578             <outline type=\"rss\" text=\"Pub\" xmlUrl=\"{AT_URI_SUB}\"/>\n\
9579             </body></opml>"
9580        );
9581        let (ct, body) = opml_multipart(opml.as_bytes());
9582        let cookie = session_cookie(&state, did, None);
9583        let resp = router(state.clone())
9584            .oneshot(
9585                Request::builder()
9586                    .method("POST")
9587                    .uri("/opml")
9588                    .header(header::COOKIE, cookie)
9589                    .header("content-type", ct)
9590                    .body(Body::from(body))
9591                    .unwrap(),
9592            )
9593            .await
9594            .unwrap();
9595        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9596        let loc = resp
9597            .headers()
9598            .get(header::LOCATION)
9599            .unwrap()
9600            .to_str()
9601            .unwrap();
9602        assert!(
9603            loc.contains("Imported%202%20feeds"),
9604            "unexpected flash: {loc}"
9605        );
9606        assert!(
9607            !loc.contains("skipped"),
9608            "the at:// entry was skipped with the flag on: {loc}"
9609        );
9610        let stored = store::get_feed_by_url(&state.db, AT_URI_SUB).await.unwrap();
9611        assert!(
9612            stored.is_some(),
9613            "the at:// entry was not stored with the flag on"
9614        );
9615    }
9616
9617    /// **A retitle must not cache a secret-bearing URL.** Moving the privacy
9618    /// gate behind `url_changed` was right for the PDS write — the record is
9619    /// the reader's — but the cache write was gated only on `storable`, which
9620    /// any http(s) URL is. So a retitle of a record another client wrote with
9621    /// a tokened feed URL inserted that URL into the shared `feeds` table,
9622    /// where the poller would fail it every cycle and print it on the admin
9623    /// page. main refused the whole rename; this keeps the record editable and
9624    /// the cache clean, as `resolve_subscriptions` already does for the same
9625    /// record.
9626    #[tokio::test]
9627    async fn retitling_a_secret_bearing_record_does_not_cache_its_url() {
9628        let did = "did:plc:renamer5";
9629        let tokened = "https://www.patreon.com/rss/author?auth=Zm9vYmFyc2VjcmV0dG9rZW4";
9630        let tokened_enc =
9631            "https%3A%2F%2Fwww.patreon.com%2Frss%2Fauthor%3Fauth%3DZm9vYmFyc2VjcmV0dG9rZW4";
9632        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription_with_url(tokened)).await;
9633        let state = test_state_with_sidecar(&[did], &sidecar).await;
9634        let loc = retitle_unchanged(&state, did, tokened_enc).await;
9635        assert_eq!(loc, "/", "the retitle was refused: {loc}");
9636        assert_eq!(
9637            puts.lock().unwrap().len(),
9638            1,
9639            "the retitle did not reach the PDS"
9640        );
9641        assert!(
9642            store::get_feed_by_url(&state.db, tokened)
9643                .await
9644                .unwrap()
9645                .is_none(),
9646            "a secret-bearing URL was written to the shared cache by a retitle"
9647        );
9648    }
9649
9650    /// **On a repoint, storability is decided before privacy and capacity** —
9651    /// the same ordering the add path got. A malformed at:// target drew the
9652    /// private/paid flash, and at capacity a well-formed one drew "try again
9653    /// later" for a URL that can never be accepted with the flag off.
9654    #[tokio::test]
9655    async fn repointing_at_a_malformed_at_uri_is_refused_as_unsupported() {
9656        let did = "did:plc:renamer4";
9657        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
9658        let state = test_state_with_sidecar(&[did], &sidecar).await;
9659        let cookie = session_cookie(&state, did, None);
9660        let resp = router(state.clone())
9661            .oneshot(
9662                Request::builder()
9663                    .method("POST")
9664                    .uri("/subscriptions/rk-keep/rename")
9665                    .header(header::COOKIE, cookie)
9666                    .header("content-type", "application/x-www-form-urlencoded")
9667                    .body(Body::from(
9668                        "url=at%3A%2F%2Fdid%3Aplc%3ATOOSHORT%2Fsite.standard.publication%2F3lab&title=Moved",
9669                    ))
9670                    .unwrap(),
9671            )
9672            .await
9673            .unwrap();
9674        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9675        let loc = resp
9676            .headers()
9677            .get(header::LOCATION)
9678            .unwrap()
9679            .to_str()
9680            .unwrap();
9681        assert!(
9682            loc.contains("kind%20of%20feed"),
9683            "expected the unsupported flash: {loc}"
9684        );
9685        assert!(
9686            !loc.contains("Private"),
9687            "a typo was reported as a paid feed: {loc}"
9688        );
9689        assert!(puts.lock().unwrap().is_empty());
9690    }
9691
9692    #[tokio::test]
9693    async fn repointing_at_an_at_uri_at_capacity_is_refused_as_unsupported_not_capacity() {
9694        let did = "did:plc:renamer4";
9695        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
9696        let state = test_state_with_sidecar_and(&[did], &sidecar, false, 1).await;
9697        store::upsert_feed(
9698            &state.db,
9699            &store::NewFeed {
9700                url: "https://filler.example/feed.xml".to_string(),
9701                ..Default::default()
9702            },
9703        )
9704        .await
9705        .unwrap();
9706        let cookie = session_cookie(&state, did, None);
9707        let resp = router(state.clone())
9708            .oneshot(
9709                Request::builder()
9710                    .method("POST")
9711                    .uri("/subscriptions/rk-keep/rename")
9712                    .header(header::COOKIE, cookie)
9713                    .header("content-type", "application/x-www-form-urlencoded")
9714                    .body(Body::from(format!("url={AT_URI_SUB_ENC}&title=Moved")))
9715                    .unwrap(),
9716            )
9717            .await
9718            .unwrap();
9719        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9720        let loc = resp
9721            .headers()
9722            .get(header::LOCATION)
9723            .unwrap()
9724            .to_str()
9725            .unwrap();
9726        assert!(
9727            loc.contains("kind%20of%20feed"),
9728            "expected the unsupported flash: {loc}"
9729        );
9730        assert!(
9731            !loc.contains("capacity"),
9732            "an unacceptable URL was reported as a capacity problem: {loc}"
9733        );
9734        assert!(puts.lock().unwrap().is_empty());
9735    }
9736
9737    /// **`url_changed` compares like for like.** The form value is trimmed;
9738    /// the record's URL was compared raw, so a record another client wrote
9739    /// with a trailing space read as a repoint on every retitle and re-armed
9740    /// every gate — including the one that made an at:// record un-editable.
9741    #[tokio::test]
9742    async fn retitling_a_record_whose_url_carries_whitespace_is_not_a_repoint() {
9743        let did = "did:plc:renamer5";
9744        let padded = format!("{AT_URI_SUB} ");
9745        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription_with_url(&padded)).await;
9746        let state = test_state_with_sidecar(&[did], &sidecar).await;
9747        // The manage row posts the record's URL verbatim, padding included.
9748        let loc = retitle_unchanged(&state, did, &format!("{AT_URI_SUB_ENC}%20")).await;
9749        assert_eq!(
9750            loc, "/",
9751            "the retitle was treated as a repoint and refused: {loc}"
9752        );
9753        let bodies = puts.lock().unwrap().clone();
9754        assert_eq!(bodies.len(), 1);
9755        let sent: serde_json::Value = serde_json::from_str(&bodies[0]).unwrap();
9756        assert_eq!(
9757            sent["record"]["url"], AT_URI_SUB,
9758            "the padding was not normalised away"
9759        );
9760    }
9761
9762    /// **A retitle inserts no cache row.** The ceiling is checked on a repoint
9763    /// only, so the trailing upsert must not create a row for an unchanged URL
9764    /// that has none — with the flag on and the cache full, each retitle of a
9765    /// never-cached at:// record was a row past the cap. An existing row still
9766    /// gets its title kept in step.
9767    #[tokio::test]
9768    async fn retitling_an_uncached_record_at_capacity_inserts_no_row() {
9769        let did = "did:plc:renamer5";
9770        let (sidecar, puts) = spawn_rename_sidecar(seeded_at_uri_subscription()).await;
9771        let state = test_state_with_sidecar_and(&[did], &sidecar, true, 1).await;
9772        store::upsert_feed(
9773            &state.db,
9774            &store::NewFeed {
9775                url: "https://filler.example/feed.xml".to_string(),
9776                ..Default::default()
9777            },
9778        )
9779        .await
9780        .unwrap();
9781        let loc = retitle_unchanged(&state, did, AT_URI_SUB_ENC).await;
9782        assert_eq!(loc, "/", "the retitle was refused: {loc}");
9783        assert_eq!(puts.lock().unwrap().len(), 1);
9784        assert_eq!(
9785            store::count_feeds(&state.db).await.unwrap(),
9786            1,
9787            "a retitle inserted a cache row past the ceiling"
9788        );
9789    }
9790
9791    /// **The add path's at:// pre-check is about the MESSAGE, so it is
9792    /// case-insensitive.** `Url::parse` folds the scheme, so `AT://…` skipped
9793    /// the pre-check and the classifier's at:// arm alike, parsed as `at`, and
9794    /// tripped the secret heuristic on the rkey — the private/paid flash the
9795    /// pre-check exists to avoid. Storage stays case-sensitive; this does not
9796    /// touch it.
9797    #[tokio::test]
9798    async fn an_uppercase_at_scheme_paste_is_refused_as_unsupported() {
9799        let did = "did:plc:typoist";
9800        let state = test_state_with_caps(did, 0, 0).await;
9801        let cookie = session_cookie(&state, did, None);
9802        let resp = router(state.clone())
9803            .oneshot(
9804                Request::builder()
9805                    .method("POST")
9806                    .uri("/subscriptions")
9807                    .header(header::COOKIE, cookie)
9808                    .header("content-type", "application/x-www-form-urlencoded")
9809                    .body(Body::from(
9810                        "url=AT%3A%2F%2Falice.example.com%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
9811                    ))
9812                    .unwrap(),
9813            )
9814            .await
9815            .unwrap();
9816        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9817        let loc = resp
9818            .headers()
9819            .get(header::LOCATION)
9820            .unwrap()
9821            .to_str()
9822            .unwrap();
9823        assert!(
9824            loc.contains("kind%20of%20feed"),
9825            "expected the unsupported flash: {loc}"
9826        );
9827        assert!(!loc.contains("Private"), "reported as a paid feed: {loc}");
9828    }
9829
9830    /// **A rename must not destroy the fields the form never carries.**
9831    ///
9832    /// `update_subscription` is a `putRecord` — the WHOLE record is replaced, per
9833    /// its own doc. The handler built a fresh `Subscription::new(url, now())`, so
9834    /// every field absent from `templates/manage_row.html` (which posts only
9835    /// `url`, `title`, `folder`) was written back as its default:
9836    ///
9837    /// | field | before | after |
9838    /// |---|---|---|
9839    /// | `siteUrl` | whatever the feed advertised | gone |
9840    /// | `fetchHint` | as set | gone |
9841    /// | `private` | as set | gone |
9842    /// | `createdAt` | original subscribe time | reset to now |
9843    ///
9844    /// `createdAt` is the worst of the four: it is the sort key for "when did I
9845    /// subscribe", it is unrecoverable once overwritten, and nothing in the UI
9846    /// tells the reader it moved.
9847    ///
9848    /// Asserted on the BYTES THE SIDECAR RECEIVES, not on a `Subscription` built
9849    /// in the test — the record only becomes wrong on the way out, so checking
9850    /// the value we passed in would pass just as happily with the fix removed.
9851    #[tokio::test]
9852    async fn renaming_preserves_the_fields_the_form_never_carries() {
9853        let did = "did:plc:renamer4";
9854        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
9855        let state = test_state_with_sidecar(&[did], &sidecar).await;
9856        let cookie = session_cookie(&state, did, None);
9857
9858        let resp = router(state.clone())
9859            .oneshot(
9860                Request::builder()
9861                    .method("POST")
9862                    .uri("/subscriptions/rk-keep/rename")
9863                    .header(header::COOKIE, cookie)
9864                    .header("content-type", "application/x-www-form-urlencoded")
9865                    // Exactly what the manage row posts: url, title, folder.
9866                    .body(Body::from(
9867                        "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=New+title&folder=Tech",
9868                    ))
9869                    .unwrap(),
9870            )
9871            .await
9872            .unwrap();
9873        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9874
9875        let bodies = puts.lock().unwrap().clone();
9876        assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
9877        let body = &bodies[0];
9878        // Anchors the negative assertions: an empty capture would satisfy them.
9879        assert!(
9880            body.contains("community.lexicon.rss.subscription"),
9881            "captured no usable put body: {body:?}"
9882        );
9883
9884        let sent: serde_json::Value = serde_json::from_str(body).expect("put body is JSON");
9885        let record = &sent["record"];
9886
9887        // What the form DID carry must be applied.
9888        assert_eq!(record["title"], "New title", "the rename did not apply");
9889        assert_eq!(record["folder"], "Tech", "the re-folder did not apply");
9890
9891        // What the form did NOT carry must survive.
9892        assert_eq!(
9893            record["createdAt"], "2024-03-01T00:00:00.000Z",
9894            "the rename reset createdAt — the reader's subscribe time is gone \
9895             from their own repo, and nothing told them"
9896        );
9897        assert_eq!(
9898            record["siteUrl"], "https://example.com/blog",
9899            "the rename erased siteUrl"
9900        );
9901        assert_eq!(record["fetchHint"], "hourly", "the rename erased fetchHint");
9902        assert_eq!(record["private"], false, "the rename erased private");
9903    }
9904
9905    /// **Repointing at a different feed drops that feed's properties, but not
9906    /// the subscription's.**
9907    ///
9908    /// `siteUrl` and `fetchHint` describe the feed the subscription points at,
9909    /// so carrying them onto a different URL would leave a site link for the old
9910    /// feed hanging off the new one. `createdAt` and `private` are properties of
9911    /// the SUBSCRIPTION and survive a repoint — the reader subscribed when they
9912    /// subscribed, whatever the URL was later corrected to.
9913    #[tokio::test]
9914    async fn repointing_a_feed_drops_the_old_feeds_properties_but_keeps_the_subscriptions() {
9915        let did = "did:plc:renamer4";
9916        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
9917        let state = test_state_with_sidecar(&[did], &sidecar).await;
9918        let cookie = session_cookie(&state, did, None);
9919
9920        let resp = router(state.clone())
9921            .oneshot(
9922                Request::builder()
9923                    .method("POST")
9924                    .uri("/subscriptions/rk-keep/rename")
9925                    .header(header::COOKIE, cookie)
9926                    .header("content-type", "application/x-www-form-urlencoded")
9927                    // A DIFFERENT feed URL from the seeded record.
9928                    .body(Body::from(
9929                        "url=https%3A%2F%2Fother.example%2Ffeed.xml&title=Repointed",
9930                    ))
9931                    .unwrap(),
9932            )
9933            .await
9934            .unwrap();
9935        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9936
9937        let bodies = puts.lock().unwrap().clone();
9938        assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
9939        assert!(
9940            bodies[0].contains("community.lexicon.rss.subscription"),
9941            "captured no usable put body: {:?}",
9942            bodies[0]
9943        );
9944        let sent: serde_json::Value = serde_json::from_str(&bodies[0]).expect("put body is JSON");
9945        let record = &sent["record"];
9946
9947        assert_eq!(record["url"], "https://other.example/feed.xml");
9948        // The old feed's properties are gone rather than misattributed.
9949        assert!(
9950            record.get("siteUrl").is_none() || record["siteUrl"].is_null(),
9951            "the old feed's site link followed the subscription to a new feed: {record}"
9952        );
9953        assert!(
9954            record.get("fetchHint").is_none() || record["fetchHint"].is_null(),
9955            "the old feed's fetch hint followed the subscription to a new feed: {record}"
9956        );
9957        // The subscription's own properties survive.
9958        assert_eq!(
9959            record["createdAt"], "2024-03-01T00:00:00.000Z",
9960            "a repoint is still not a new subscription; createdAt must not move"
9961        );
9962        assert_eq!(record["private"], false, "the repoint erased private");
9963    }
9964
9965    /// **A rename against an rkey that is not in the repo writes NOTHING.**
9966    ///
9967    /// `update_subscription` is a `putRecord`, which CREATES the record when the
9968    /// rkey does not exist — with whatever `createdAt` we hand it. So without
9969    /// this refusal a rename against a stale or wrong rkey manufactures a
9970    /// subscription dated today, which is the bug this whole change exists to
9971    /// fix, arriving by a different door.
9972    ///
9973    /// The guard was untested when first written: removing it left all 733 tests
9974    /// green. An untested guard against the exact defect being fixed is how the
9975    /// two previous rounds of this problem got through.
9976    #[tokio::test]
9977    async fn renaming_an_unknown_rkey_writes_nothing() {
9978        let did = "did:plc:renamer4";
9979        // The sidecar serves exactly one record, at rkey `rk-keep`.
9980        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
9981        let state = test_state_with_sidecar(&[did], &sidecar).await;
9982        let cookie = session_cookie(&state, did, None);
9983
9984        let resp = router(state.clone())
9985            .oneshot(
9986                Request::builder()
9987                    .method("POST")
9988                    // ...and this is not it.
9989                    .uri("/subscriptions/rk-does-not-exist/rename")
9990                    .header(header::COOKIE, cookie)
9991                    .header("content-type", "application/x-www-form-urlencoded")
9992                    .body(Body::from(
9993                        "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=Ghost",
9994                    ))
9995                    .unwrap(),
9996            )
9997            .await
9998            .unwrap();
9999
10000        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10001        let loc = resp
10002            .headers()
10003            .get(header::LOCATION)
10004            .unwrap()
10005            .to_str()
10006            .unwrap();
10007        assert!(
10008            loc.contains("flash="),
10009            "an unknown rkey redirected as though the rename had worked: {loc}"
10010        );
10011        assert!(
10012            puts.lock().unwrap().is_empty(),
10013            "a rename against an unknown rkey wrote a record — putRecord would \
10014             CREATE it, dated today: {:?}",
10015            puts.lock().unwrap()
10016        );
10017    }
10018
10019    /// **A `site_url` the client actually sends is applied, not dropped.**
10020    ///
10021    /// `templates/manage_row.html` does not post this field, so it is tempting
10022    /// to read the arm that handles it as dead code. It is not:
10023    /// `RenameSubForm` carries `site_url`, so a hand-crafted POST reaches it
10024    /// today. Discarding the value instead of applying it left all 733 tests
10025    /// green.
10026    ///
10027    /// The value is scheme-checked on the way out by the repo-boundary vet, so
10028    /// this is a coverage gap rather than an exposure — but an untested path
10029    /// that writes a URL into the reader's PDS should not stay untested.
10030    #[tokio::test]
10031    async fn a_client_supplied_site_url_reaches_the_record() {
10032        let did = "did:plc:renamer4";
10033        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
10034        let state = test_state_with_sidecar(&[did], &sidecar).await;
10035        let cookie = session_cookie(&state, did, None);
10036
10037        let resp = router(state.clone())
10038            .oneshot(
10039                Request::builder()
10040                    .method("POST")
10041                    .uri("/subscriptions/rk-keep/rename")
10042                    .header(header::COOKIE, cookie)
10043                    .header("content-type", "application/x-www-form-urlencoded")
10044                    // Same feed URL, but carrying a site_url the manage row
10045                    // never sends.
10046                    .body(Body::from(
10047                        "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=Kept\
10048                         &site_url=https%3A%2F%2Ftyped.example%2Fsite",
10049                    ))
10050                    .unwrap(),
10051            )
10052            .await
10053            .unwrap();
10054        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10055
10056        let bodies = puts.lock().unwrap().clone();
10057        assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
10058        assert!(
10059            bodies[0].contains("community.lexicon.rss.subscription"),
10060            "captured no usable put body: {:?}",
10061            bodies[0]
10062        );
10063        let sent: serde_json::Value = serde_json::from_str(&bodies[0]).expect("put body is JSON");
10064        assert_eq!(
10065            sent["record"]["siteUrl"], "https://typed.example/site",
10066            "the client's siteUrl was dropped; the seeded record's survived instead"
10067        );
10068    }
10069
10070    /// **A rename whose read fails writes NOTHING.**
10071    ///
10072    /// This is the property most easily lost when someone later touches this
10073    /// handler: falling back to `Subscription::new` on a read error looks like
10074    /// graceful degradation and is in fact the original bug, reinstated on
10075    /// exactly the path where it is hardest to notice. The reader must be told
10076    /// instead.
10077    #[tokio::test]
10078    async fn a_rename_whose_read_fails_writes_nothing() {
10079        let did = "did:plc:renamer5";
10080        // A port that accepts nothing: the read cannot succeed.
10081        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
10082        let dead = format!("http://{}", listener.local_addr().unwrap());
10083        drop(listener);
10084
10085        let state = test_state_with_sidecar(&[did], &dead).await;
10086        let cookie = session_cookie(&state, did, None);
10087        let before = store::count_feeds(&state.db).await.unwrap();
10088
10089        let resp = router(state.clone())
10090            .oneshot(
10091                Request::builder()
10092                    .method("POST")
10093                    .uri("/subscriptions/rk-keep/rename")
10094                    .header(header::COOKIE, cookie)
10095                    .header("content-type", "application/x-www-form-urlencoded")
10096                    .body(Body::from(
10097                        "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=Doomed",
10098                    ))
10099                    .unwrap(),
10100            )
10101            .await
10102            .unwrap();
10103
10104        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10105        let loc = resp
10106            .headers()
10107            .get(header::LOCATION)
10108            .unwrap()
10109            .to_str()
10110            .unwrap();
10111        assert!(
10112            loc.contains("flash="),
10113            "a failed read redirected as though the rename had worked: {loc}"
10114        );
10115        assert_eq!(
10116            store::count_feeds(&state.db).await.unwrap(),
10117            before,
10118            "a rename that could not read the record still wrote to the cache"
10119        );
10120    }
10121
10122    /// Folder pre-selection regression: the manage rename row must mark the
10123    /// feed's CURRENT folder `<option>` as `selected`, so an untouched Save
10124    /// re-submits the current folder instead of silently un-foldering the feed.
10125    /// A loose (un-foldered) feed must mark "No folder" selected instead. Renders
10126    /// `ManageTemplate` directly so no PDS/sidecar round-trip is needed.
10127    #[test]
10128    fn manage_rename_row_preselects_current_folder() {
10129        let nav = Nav {
10130            handle: "@reader.example".to_string(),
10131            avatar: "RE".to_string(),
10132            view: "unread".to_string(),
10133            scope_qs: String::new(),
10134            folders: Vec::new(),
10135            loose_feeds: Vec::new(),
10136            manage_active: true,
10137        };
10138        let folder_options = vec![
10139            FolderOption {
10140                uri: "at://did:plc:x/app.folder/work".to_string(),
10141                name: "Work".to_string(),
10142            },
10143            FolderOption {
10144                uri: "at://did:plc:x/app.folder/fun".to_string(),
10145                name: "Fun".to_string(),
10146            },
10147        ];
10148        // A foldered feed (in "Work") and a loose feed (no folder), each with a
10149        // non-empty rkey so the rename form renders.
10150        let foldered = FeedView {
10151            rkey: "sub-foldered".to_string(),
10152            url: "https://work.example/feed.xml".to_string(),
10153            title: "Work Feed".to_string(),
10154            unread: 0,
10155            selected: false,
10156            folder: Some("at://did:plc:x/app.folder/work".to_string()),
10157        };
10158        let loose = FeedView {
10159            rkey: "sub-loose".to_string(),
10160            url: "https://loose.example/feed.xml".to_string(),
10161            title: "Loose Feed".to_string(),
10162            unread: 0,
10163            selected: false,
10164            folder: None,
10165        };
10166        let tmpl = ManageTemplate {
10167            version: VERSION,
10168            repo_url: REPO_URL,
10169            kofi_url: KOFI_URL,
10170            flash: String::new(),
10171            nav,
10172            folder_options,
10173            folders: vec![FolderView {
10174                rkey: "folder-work".to_string(),
10175                uri: "at://did:plc:x/app.folder/work".to_string(),
10176                name: "Work".to_string(),
10177                feeds: vec![foldered],
10178                selected: false,
10179            }],
10180            loose_feeds: vec![loose],
10181        };
10182        let html = tmpl.render().unwrap();
10183
10184        // The foldered feed's "Work" option is pre-selected.
10185        assert!(
10186            html.contains(
10187                r#"<option value="at://did:plc:x/app.folder/work" selected>Work</option>"#
10188            ),
10189            "foldered feed must pre-select its current folder: {html}"
10190        );
10191        // The loose feed's "No folder" option is pre-selected (appears for the
10192        // loose row, which has folder=None).
10193        assert!(
10194            html.contains(r#"<option value="" selected>No folder</option>"#),
10195            "loose feed must pre-select 'No folder': {html}"
10196        );
10197    }
10198
10199    /// **The public stats page carries no user data.**
10200    ///
10201    /// It is reachable by anyone, so the thing worth pinning is what it does
10202    /// NOT say: nothing about how many people use the instance, nothing about
10203    /// which feeds fail, nothing about who reads what.
10204    #[tokio::test]
10205    async fn the_public_stats_page_exposes_no_user_data() {
10206        let state = test_state(&[]).await;
10207        store::ensure_seed(&state.db, &["did:plc:someone".to_string()])
10208            .await
10209            .unwrap();
10210
10211        let resp = router(state)
10212            .oneshot(
10213                Request::builder()
10214                    .uri("/stats")
10215                    .body(Body::empty())
10216                    .unwrap(),
10217            )
10218            .await
10219            .unwrap();
10220        assert_eq!(resp.status(), StatusCode::OK, "stats must be public");
10221
10222        let body = String::from_utf8(
10223            axum::body::to_bytes(resp.into_body(), usize::MAX)
10224                .await
10225                .unwrap()
10226                .to_vec(),
10227        )
10228        .unwrap();
10229
10230        // Structural checks, not word checks. The page's own prose says it
10231        // publishes no error rates, so searching for that PHRASE finds the
10232        // disclaimer rather than a leak — the first version of this test failed
10233        // on exactly that. What matters is whether identifiers or the
10234        // admin-only figures are present.
10235        assert!(
10236            !body.contains("did:"),
10237            "the public stats page leaked an identifier"
10238        );
10239        for admin_only in ["errp50ms", "p95ms", "live backend", "ok_count"] {
10240            assert!(
10241                !body.contains(admin_only),
10242                "the public page is showing the admin metrics column {admin_only:?}"
10243            );
10244        }
10245        // And it does render the aggregate it exists for.
10246        assert!(body.contains("Feeds tracked"));
10247        assert!(body.contains("Waiting to be polled"));
10248    }
10249
10250    /// **The two states that stop feeds updating must be visible.**
10251    ///
10252    /// `overdue` and `polled_last_hour` move in BOTH and distinguish neither —
10253    /// and `overdue` moves the WRONG WAY for backoff, because backoff is applied
10254    /// by pushing `next_poll` forward, so a feed failing every fetch drops out of
10255    /// the backlog and makes the page read healthier. That inversion is what this
10256    /// test pins: a broken feed must raise a number, not lower one.
10257    #[tokio::test]
10258    async fn stats_distinguishes_backoff_from_a_watermark_pause() {
10259        let state = test_state(&[]).await;
10260        // Three feeds: one healthy, one flaky, one long dead.
10261        for (url, errors) in [
10262            ("https://ok.example/f.xml", 0),
10263            ("https://flaky.example/f.xml", 2),
10264            ("https://dead.example/f.xml", 9),
10265        ] {
10266            store::upsert_feed(
10267                &state.db,
10268                &store::NewFeed {
10269                    url: url.to_string(),
10270                    // Pushed forward, exactly as backoff does — so none of these
10271                    // are counted as `overdue`.
10272                    next_poll: Some("2099-01-01T00:00:00Z".to_string()),
10273                    ..Default::default()
10274                },
10275            )
10276            .await
10277            .unwrap();
10278            for _ in 0..errors {
10279                store::bump_feed_errors(
10280                    &state.db,
10281                    url,
10282                    feed::FailureKind::Fetch,
10283                    "connection refused",
10284                )
10285                .await
10286                .unwrap();
10287            }
10288        }
10289
10290        let render_stats = |state: AppState| async move {
10291            let resp = router(state)
10292                .oneshot(
10293                    Request::builder()
10294                        .uri("/stats")
10295                        .body(Body::empty())
10296                        .unwrap(),
10297                )
10298                .await
10299                .unwrap();
10300            assert_eq!(resp.status(), StatusCode::OK);
10301            String::from_utf8(
10302                axum::body::to_bytes(resp.into_body(), usize::MAX)
10303                    .await
10304                    .unwrap()
10305                    .to_vec(),
10306            )
10307            .unwrap()
10308        };
10309
10310        // **The fixture must actually be RUNNING, or this test measures nothing.**
10311        // `test_state` leaves `schedulers_enabled` false, and `fetching_state`
10312        // checks that BEFORE the watermark — so without these two lines every
10313        // render below reports "off" and the watermark can never surface. The
10314        // assertions still passed, for reasons unrelated to what they name: see
10315        // the two comments below.
10316        state.runtime_health.set_schedulers_enabled(true);
10317        state
10318            .runtime_health
10319            .poll_tick_completed(crate::store::now_unix());
10320
10321        let body = render_stats(state.clone()).await;
10322        assert!(
10323            body.contains("Failing"),
10324            "backoff is still invisible on the public page"
10325        );
10326        // 2 failing, 1 of them badly (>= BADLY_BROKEN_ERRORS). Matched on the
10327        // value rather than on surrounding whitespace, so re-indenting the
10328        // template cannot break this.
10329        assert!(
10330            body.contains("2, 1 badly"),
10331            "expected '2, 1 badly' in the failing row; got:\n{}",
10332            body.split("Failing")
10333                .nth(1)
10334                .unwrap_or("")
10335                .chars()
10336                .take(300)
10337                .collect::<String>()
10338        );
10339        // Not paused, and the backlog is genuinely empty — which is exactly the
10340        // reading that used to be indistinguishable from healthy.
10341        //
10342        // **Asserted by EXCLUDING the other states, not by matching "running".**
10343        // The `off` row reads "the poller is not running on this instance", which
10344        // contains "running" — so the bare substring passed while the page was
10345        // reporting the exact opposite of what this line claims to check.
10346        assert!(
10347            !body.contains("the poller is not running")
10348                && !body.contains("the cache is at its size limit")
10349                && !body.contains("has not completed a round"),
10350            "expected the running state; the page reported a stopped one",
10351        );
10352
10353        // Now trip the watermark. Nothing in the database changes; only the
10354        // recorded runtime state does — which is the whole reason it needed a
10355        // home outside the log stream.
10356        state.runtime_health.set_watermark(true);
10357        let paused = render_stats(state.clone()).await;
10358        // Matched on the paused row's OWN sentence. The bare word "paused" also
10359        // appeared in the page's explanatory prose, so this assertion passed
10360        // whether or not the row rendered — and trimming that prose is what
10361        // exposed it. This phrase exists only inside the `paused` branch.
10362        assert!(
10363            paused.contains("the cache is at its size limit"),
10364            "a watermark pause is still invisible on the public page"
10365        );
10366
10367        // Still no identifiers: these are counts, not feeds.
10368        for leak in ["ok.example", "flaky.example", "dead.example", "did:"] {
10369            assert!(
10370                !paused.contains(leak),
10371                "the public page leaked {leak:?} while reporting failures"
10372            );
10373        }
10374    }
10375
10376    /// **`/admin/metrics` is gated, and nothing checked that it was.**
10377    ///
10378    /// Deleting the `admin_seed_dids` check left the entire suite green. That
10379    /// was survivable while the page held only aggregate timings; it is not now,
10380    /// because this branch puts **per-feed URLs and remote error text** behind
10381    /// that gate. A guarantee nothing checks is a comment, and this one is now
10382    /// the only thing standing between a signed-in stranger and the operational
10383    /// picture the handler's own doc says is not public.
10384    ///
10385    /// All three doors: no session, a session that is not an admin, and the
10386    /// admin itself.
10387    #[tokio::test]
10388    async fn admin_metrics_is_refused_to_everyone_but_an_admin() {
10389        let admin = "did:plc:adminseed";
10390        // **Only the admin is in ALLOWED_DIDS**, because `admin_seed_dids()`
10391        // IS that list — deliberately, per its doc: "the same people I trust on
10392        // this instance". Production sets it to the bootstrap DID alone.
10393        //
10394        // A genuine non-admin is therefore someone holding a beta seat granted
10395        // by an invite, not by the allow-list. Seeding both would have made
10396        // both admins and quietly turned the 403 assertion below into a test of
10397        // nothing — which is exactly what the first draft of this did.
10398        let state = test_state(&[admin]).await;
10399        store::grant_access(&state.db, "did:plc:ordinaryuser", None, "invite", None)
10400            .await
10401            .unwrap();
10402        let url = "https://broken.example/f.xml";
10403        store::upsert_feed(
10404            &state.db,
10405            &store::NewFeed {
10406                url: url.to_string(),
10407                ..Default::default()
10408            },
10409        )
10410        .await
10411        .unwrap();
10412        store::bump_feed_errors(
10413            &state.db,
10414            url,
10415            feed::FailureKind::Fetch,
10416            "SENTINEL_ADMIN_ONLY",
10417        )
10418        .await
10419        .unwrap();
10420
10421        let get = |state: AppState, cookie: Option<String>| async move {
10422            let mut req = Request::builder().uri("/admin/metrics");
10423            if let Some(c) = cookie {
10424                req = req.header(header::COOKIE, c);
10425            }
10426            let resp = router(state)
10427                .oneshot(req.body(Body::empty()).unwrap())
10428                .await
10429                .unwrap();
10430            let status = resp.status();
10431            let body = String::from_utf8(
10432                axum::body::to_bytes(resp.into_body(), usize::MAX)
10433                    .await
10434                    .unwrap()
10435                    .to_vec(),
10436            )
10437            .unwrap();
10438            (status, body)
10439        };
10440
10441        // No session at all.
10442        let (status, body) = get(state.clone(), None).await;
10443        assert_eq!(status, StatusCode::UNAUTHORIZED);
10444        assert!(
10445            !body.contains("SENTINEL_ADMIN_ONLY"),
10446            "leaked to anonymous: {body}"
10447        );
10448
10449        // A real, signed-in user who is not an admin.
10450        let ordinary = session_cookie(&state, "did:plc:ordinaryuser", None);
10451        let (status, body) = get(state.clone(), Some(ordinary)).await;
10452        assert_eq!(
10453            status,
10454            StatusCode::FORBIDDEN,
10455            "a non-admin session was let in"
10456        );
10457        assert!(
10458            !body.contains("SENTINEL_ADMIN_ONLY") && !body.contains("broken.example"),
10459            "leaked to a non-admin: {body}",
10460        );
10461
10462        // The admin does get it — otherwise the two refusals above are
10463        // satisfied by the endpoint being broken for everyone.
10464        let admin_cookie = session_cookie(&state, admin, None);
10465        let (status, body) = get(state, Some(admin_cookie)).await;
10466        assert_eq!(status, StatusCode::OK);
10467        assert!(
10468            body.contains("SENTINEL_ADMIN_ONLY"),
10469            "admin cannot see it: {body}"
10470        );
10471    }
10472
10473    /// **The cause a public count cannot carry belongs on the admin page.**
10474    ///
10475    /// The public histogram is four coarse buckets, and `fetch` is the coarsest:
10476    /// #159's own error — `guarded_get` bailing on a 304 — lands there beside
10477    /// DNS failure, timeout and SSRF refusal. So the histogram alone would NOT
10478    /// have separated "sixty dead publishers" from "one bug here", which is the
10479    /// case it was justified by.
10480    ///
10481    /// The answer is not a finer public vocabulary — `/stats` promises never
10482    /// which feed and never whose, and a bucket per error string would break
10483    /// that. It is to put the detail where per-feed data is already allowed.
10484    /// `/admin/metrics` is gated on `ALLOWED_DIDS` and already carries an
10485    /// operational picture.
10486    ///
10487    /// Asserts both halves: the detail IS on the admin page, and is NOT on the
10488    /// public one.
10489    #[tokio::test]
10490    async fn the_admin_page_names_failing_feeds_and_the_public_page_does_not() {
10491        let admin = "did:plc:adminseed";
10492        let state = test_state(&[admin]).await;
10493        let url = "https://broken.example/f.xml";
10494        store::upsert_feed(
10495            &state.db,
10496            &store::NewFeed {
10497                url: url.to_string(),
10498                ..Default::default()
10499            },
10500        )
10501        .await
10502        .unwrap();
10503        store::bump_feed_errors(
10504            &state.db,
10505            url,
10506            feed::FailureKind::Fetch,
10507            "SENTINEL_REDIRECT_NO_LOCATION",
10508        )
10509        .await
10510        .unwrap();
10511
10512        let cookie = session_cookie(&state, admin, None);
10513        let resp = router(state.clone())
10514            .oneshot(
10515                Request::builder()
10516                    .uri("/admin/metrics")
10517                    .header(header::COOKIE, cookie)
10518                    .body(Body::empty())
10519                    .unwrap(),
10520            )
10521            .await
10522            .unwrap();
10523        assert_eq!(resp.status(), StatusCode::OK);
10524        let admin_body = String::from_utf8(
10525            axum::body::to_bytes(resp.into_body(), usize::MAX)
10526                .await
10527                .unwrap()
10528                .to_vec(),
10529        )
10530        .unwrap();
10531        assert!(
10532            admin_body.contains("SENTINEL_REDIRECT_NO_LOCATION"),
10533            "the admin page does not carry the failure detail: {admin_body}",
10534        );
10535        assert!(
10536            admin_body.contains("broken.example"),
10537            "the admin page does not name the failing feed: {admin_body}",
10538        );
10539
10540        // The public page still carries neither.
10541        let resp = router(state)
10542            .oneshot(
10543                Request::builder()
10544                    .uri("/stats")
10545                    .body(Body::empty())
10546                    .unwrap(),
10547            )
10548            .await
10549            .unwrap();
10550        let public = String::from_utf8(
10551            axum::body::to_bytes(resp.into_body(), usize::MAX)
10552                .await
10553                .unwrap()
10554                .to_vec(),
10555        )
10556        .unwrap();
10557        for secret in ["SENTINEL_REDIRECT_NO_LOCATION", "broken.example"] {
10558            assert!(
10559                !public.contains(secret),
10560                "{secret:?} reached the PUBLIC stats page: {public}",
10561            );
10562        }
10563    }
10564
10565    /// **A direct poll must settle the error columns, like the scheduler does.**
10566    ///
10567    /// `add_subscription` polls through `feed::poll_feed` rather than the
10568    /// scheduler, and `poll_feed` writes validators and `last_polled` but never
10569    /// touches `consecutive_errors` — that is the scheduler's job, and this path
10570    /// is not the scheduler.
10571    ///
10572    /// So a feed that was failing, is re-subscribed, and polls SUCCESSFULLY kept
10573    /// its old count and its old cause: the public page went on reporting it
10574    /// under `Failing`, under `badly_broken`, and under a cause, for as long as
10575    /// the stale backoff horizon lasted — up to 24h — while the reader was
10576    /// demonstrably fetching it.
10577    #[tokio::test]
10578    async fn a_successful_direct_poll_clears_a_stale_failure() {
10579        let state = test_state(&[]).await;
10580        let url = "https://recovered.example/f.xml";
10581        store::upsert_feed(
10582            &state.db,
10583            &store::NewFeed {
10584                url: url.to_string(),
10585                ..Default::default()
10586            },
10587        )
10588        .await
10589        .unwrap();
10590        store::bump_feed_errors(&state.db, url, feed::FailureKind::Fetch, "SENTINEL_OLD")
10591            .await
10592            .unwrap();
10593        // Park it on a stale backoff horizon, as a real failing feed would be.
10594        sqlx::query("UPDATE feeds SET next_poll = '2099-01-01T00:00:00Z' WHERE url = ?1")
10595            .bind(url)
10596            .execute(&state.db)
10597            .await
10598            .unwrap();
10599
10600        // The publisher is fixed: a successful poll happens on this path.
10601        feed::settle_poll(
10602            &state.db,
10603            url,
10604            &feed::PollOutcome::NotModified,
10605            state.config.poll_interval,
10606        )
10607        .await;
10608
10609        let row: (i64, Option<String>, Option<String>) = sqlx::query_as(
10610            "SELECT consecutive_errors, last_error_kind, next_poll FROM feeds WHERE url = ?1",
10611        )
10612        .bind(url)
10613        .fetch_one(&state.db)
10614        .await
10615        .unwrap();
10616        assert_eq!(row.0, 0, "a successful direct poll left the error streak");
10617        assert_eq!(row.1, None, "a successful direct poll left a stale cause");
10618        // **The half the first fix missed.** Clearing the count fixed the
10619        // REPORTING; the feed stayed parked until 2099. A working feed must be
10620        // rescheduled on its normal cadence, not left on the failure horizon.
10621        let next = row.2.expect("next_poll was cleared to NULL");
10622        // Not merely "moved off 2099" — rescheduled on the CADENCE, not a
10623        // backoff. A mutation that reschedules successes with backoff_for(1)
10624        // (5 min) also moves it off 2099, so the interval is asserted.
10625        let parsed = chrono::DateTime::parse_from_rfc3339(&next).unwrap();
10626        let delta = parsed
10627            .signed_duration_since(chrono::Utc::now())
10628            .num_seconds();
10629        let cadence = state.config.poll_interval.as_secs() as i64;
10630        assert!(
10631            (cadence - 60..=cadence + 60).contains(&delta),
10632            "expected rescheduling on the {cadence}s cadence, got {delta}s (next_poll={next})"
10633        );
10634    }
10635
10636    /// The mirror case: a first poll that FAILS must be visible at all.
10637    ///
10638    /// `Ok(outcome) => info!(...)` discarded a `PollOutcome::Failed`, so a
10639    /// subscription whose very first fetch failed sat at `consecutive_errors = 0`
10640    /// with a NULL cause — invisible to the page built to count exactly that.
10641    #[tokio::test]
10642    async fn a_failing_direct_poll_is_recorded() {
10643        let state = test_state(&[]).await;
10644        let url = "https://born-broken.example/f.xml";
10645        store::upsert_feed(
10646            &state.db,
10647            &store::NewFeed {
10648                url: url.to_string(),
10649                ..Default::default()
10650            },
10651        )
10652        .await
10653        .unwrap();
10654
10655        feed::settle_poll(
10656            &state.db,
10657            url,
10658            &feed::PollOutcome::Failed {
10659                backoff: std::time::Duration::from_secs(300),
10660                kind: feed::FailureKind::Parse,
10661                detail: "SENTINEL_BORN_BROKEN".to_string(),
10662            },
10663            state.config.poll_interval,
10664        )
10665        .await;
10666
10667        let row: (i64, Option<String>, Option<String>) = sqlx::query_as(
10668            "SELECT consecutive_errors, last_error_kind, next_poll FROM feeds WHERE url = ?1",
10669        )
10670        .bind(url)
10671        .fetch_one(&state.db)
10672        .await
10673        .unwrap();
10674        assert_eq!(row.0, 1, "a failed first poll was not counted");
10675        assert_eq!(
10676            row.1.as_deref(),
10677            Some("parse"),
10678            "its cause was not recorded"
10679        );
10680        // And it is BACKED OFF on the schedule the scheduler would use — not
10681        // left with a NULL next_poll that `due_feeds` sorts first and re-polls
10682        // on the very next tick.
10683        let next = row.2.expect("a failed direct poll left next_poll NULL");
10684        let parsed = chrono::DateTime::parse_from_rfc3339(&next).unwrap();
10685        let delta = parsed
10686            .signed_duration_since(chrono::Utc::now())
10687            .num_seconds();
10688        assert!(
10689            (240..=360).contains(&delta),
10690            "expected ~300s backoff after one failure, got {delta}s (next_poll={next})"
10691        );
10692    }
10693
10694    /// **The breakdown must sum to the Failing figure above it.**
10695    ///
10696    /// The histogram counts `last_error_kind IS NOT NULL`; `Failing` counts
10697    /// `consecutive_errors > 0`. On a migrated database every row that was
10698    /// already failing has a NULL kind — correctly, it was never recorded — so
10699    /// the two do not reconcile and the page shows "70 failing" beside "3
10700    /// fetch" with 67 silently unaccounted for. On deploy day the row vanishes
10701    /// entirely while the prose still promises a breakdown.
10702    ///
10703    /// An explicit `unknown` bucket is the honest shape: the page says how many
10704    /// it cannot explain rather than omitting them.
10705    #[tokio::test]
10706    async fn the_failure_breakdown_accounts_for_every_failing_feed() {
10707        let state = test_state(&[]).await;
10708        // Two legacy rows: failing, with no recorded cause.
10709        for url in [
10710            "https://legacy1.example/f.xml",
10711            "https://legacy2.example/f.xml",
10712        ] {
10713            store::upsert_feed(
10714                &state.db,
10715                &store::NewFeed {
10716                    url: url.to_string(),
10717                    next_poll: Some("2099-01-01T00:00:00Z".to_string()),
10718                    ..Default::default()
10719                },
10720            )
10721            .await
10722            .unwrap();
10723            sqlx::query("UPDATE feeds SET consecutive_errors = 4 WHERE url = ?1")
10724                .bind(url)
10725                .execute(&state.db)
10726                .await
10727                .unwrap();
10728        }
10729        // One row with a recorded cause.
10730        store::upsert_feed(
10731            &state.db,
10732            &store::NewFeed {
10733                url: "https://known.example/f.xml".to_string(),
10734                next_poll: Some("2099-01-01T00:00:00Z".to_string()),
10735                ..Default::default()
10736            },
10737        )
10738        .await
10739        .unwrap();
10740        store::bump_feed_errors(
10741            &state.db,
10742            "https://known.example/f.xml",
10743            feed::FailureKind::Status,
10744            "SENTINEL",
10745        )
10746        .await
10747        .unwrap();
10748
10749        let now = chrono::Utc::now();
10750        let health = store::poll_health(
10751            &state.db,
10752            &now.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
10753            &(now - chrono::Duration::hours(1)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
10754        )
10755        .await
10756        .unwrap();
10757        let counted: i64 = health.failure_kinds.iter().map(|(_, n)| n).sum();
10758        assert_eq!(
10759            counted, health.in_backoff,
10760            "the breakdown ({counted}) does not account for all {} failing feeds: {:?}",
10761            health.in_backoff, health.failure_kinds,
10762        );
10763        assert!(
10764            health
10765                .failure_kinds
10766                .iter()
10767                .any(|(k, n)| k == "unknown" && *n == 2),
10768            "no unknown bucket for the legacy rows: {:?}",
10769            health.failure_kinds,
10770        );
10771    }
10772
10773    /// **The breakdown is ordered by count, and the assertion can see it.**
10774    ///
10775    /// The first version of this asserted with three `contains` calls, which
10776    /// cannot observe order — deleting `ORDER BY` from the query passed.
10777    #[tokio::test]
10778    async fn the_failure_breakdown_is_ordered_by_count() {
10779        let state = test_state(&[]).await;
10780        for (url, kind, n) in [
10781            ("https://p1.example/f.xml", feed::FailureKind::Parse, 1),
10782            ("https://f1.example/f.xml", feed::FailureKind::Fetch, 1),
10783            ("https://f2.example/f.xml", feed::FailureKind::Fetch, 1),
10784            ("https://f3.example/f.xml", feed::FailureKind::Fetch, 1),
10785            ("https://s1.example/f.xml", feed::FailureKind::Status, 1),
10786            ("https://s2.example/f.xml", feed::FailureKind::Status, 1),
10787        ] {
10788            store::upsert_feed(
10789                &state.db,
10790                &store::NewFeed {
10791                    url: url.to_string(),
10792                    next_poll: Some("2099-01-01T00:00:00Z".to_string()),
10793                    ..Default::default()
10794                },
10795            )
10796            .await
10797            .unwrap();
10798            for _ in 0..n {
10799                store::bump_feed_errors(&state.db, url, kind, "d")
10800                    .await
10801                    .unwrap();
10802            }
10803        }
10804        let now = chrono::Utc::now();
10805        let health = store::poll_health(
10806            &state.db,
10807            &now.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
10808            &(now - chrono::Duration::hours(1)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
10809        )
10810        .await
10811        .unwrap();
10812        let labels: Vec<&str> = health
10813            .failure_kinds
10814            .iter()
10815            .map(|(k, _)| k.as_str())
10816            .collect();
10817        assert_eq!(
10818            labels,
10819            ["fetch", "status", "parse"],
10820            "not ordered by count, descending: {:?}",
10821            health.failure_kinds,
10822        );
10823    }
10824
10825    /// **Failing feeds are grouped by CAUSE, and still never named.**
10826    ///
10827    /// `badly_broken` could say that sixty feeds were failing and not whether
10828    /// that was sixty dead publishers or one bug here. It was the latter — #159,
10829    /// a `304 Not Modified` read as a malformed redirect — and the page could
10830    /// not say so, which is most of why it went unexamined.
10831    ///
10832    /// The second half of this test is the constraint that shapes the first:
10833    /// `/stats` is public and promises machines-not-people, *never which feed
10834    /// and never whose*. A histogram of causes keeps that promise; a list of
10835    /// failing URLs would break it, and is the obvious way to build this.
10836    #[tokio::test]
10837    async fn stats_groups_failures_by_cause_without_naming_any_feed() {
10838        let state = test_state(&[]).await;
10839        for (url, kind, detail, errors) in [
10840            // Detail strings are distinctive SENTINELS, not plausible English.
10841            // A first pass used "not a feed", which the page's own explanation
10842            // of the `parse` kind contains verbatim — the privacy assertion
10843            // fired on static copy rather than on a leak. A sentinel cannot
10844            // collide with prose.
10845            (
10846                "https://a.example/f.xml",
10847                feed::FailureKind::Fetch,
10848                "SENTINEL_CONNREFUSED",
10849                3,
10850            ),
10851            (
10852                "https://b.example/f.xml",
10853                feed::FailureKind::Fetch,
10854                "SENTINEL_DNSFAIL",
10855                2,
10856            ),
10857            (
10858                "https://c.example/f.xml",
10859                feed::FailureKind::Status,
10860                "SENTINEL_404",
10861                1,
10862            ),
10863            (
10864                "https://d.example/f.xml",
10865                feed::FailureKind::Parse,
10866                "SENTINEL_UNPARSEABLE",
10867                1,
10868            ),
10869        ] {
10870            store::upsert_feed(
10871                &state.db,
10872                &store::NewFeed {
10873                    url: url.to_string(),
10874                    next_poll: Some("2099-01-01T00:00:00Z".to_string()),
10875                    ..Default::default()
10876                },
10877            )
10878            .await
10879            .unwrap();
10880            for _ in 0..errors {
10881                store::bump_feed_errors(&state.db, url, kind, detail)
10882                    .await
10883                    .unwrap();
10884            }
10885        }
10886
10887        let resp = router(state.clone())
10888            .oneshot(
10889                Request::builder()
10890                    .uri("/stats")
10891                    .body(Body::empty())
10892                    .unwrap(),
10893            )
10894            .await
10895            .unwrap();
10896        assert_eq!(resp.status(), StatusCode::OK);
10897        let body = String::from_utf8(
10898            axum::body::to_bytes(resp.into_body(), usize::MAX)
10899                .await
10900                .unwrap()
10901                .to_vec(),
10902        )
10903        .unwrap();
10904
10905        // Descending by count: two fetch, then one each, tie-broken by name.
10906        assert!(
10907            body.contains("2 fetch") && body.contains("1 status") && body.contains("1 parse"),
10908            "the cause histogram did not render: {body}",
10909        );
10910
10911        // **The privacy half.** No feed URL, host, or error detail reaches the
10912        // public page — only counts by kind.
10913        for secret in [
10914            "a.example",
10915            "b.example",
10916            "c.example",
10917            "d.example",
10918            "SENTINEL_CONNREFUSED",
10919            "SENTINEL_DNSFAIL",
10920            "SENTINEL_404",
10921            "SENTINEL_UNPARSEABLE",
10922        ] {
10923            assert!(
10924                !body.contains(secret),
10925                "{secret:?} reached the PUBLIC stats page: {body}",
10926            );
10927        }
10928    }
10929
10930    /// `/health` must prove the process can reach its database, and must report
10931    /// the loop state without letting it change the status code.
10932    #[tokio::test]
10933    async fn health_checks_the_database_and_reports_the_loops() {
10934        let state = test_state(&[]).await;
10935        let body_of = |state: AppState| async move {
10936            let resp = router(state)
10937                .oneshot(
10938                    Request::builder()
10939                        .uri("/health")
10940                        .body(Body::empty())
10941                        .unwrap(),
10942                )
10943                .await
10944                .unwrap();
10945            let status = resp.status();
10946            let body = String::from_utf8(
10947                axum::body::to_bytes(resp.into_body(), usize::MAX)
10948                    .await
10949                    .unwrap()
10950                    .to_vec(),
10951            )
10952            .unwrap();
10953            (status, body)
10954        };
10955
10956        // The boot stamp is what `main` sets; the router alone does not, so this
10957        // starts "unknown" and the uptime branch below drives it explicitly.
10958        state
10959            .runtime_health
10960            .set_started_at(chrono::Utc::now().timestamp());
10961
10962        let (status, body) = body_of(state.clone()).await;
10963        assert_eq!(status, StatusCode::OK);
10964        assert!(
10965            body.contains("db: ok"),
10966            "health did not probe the DB: {body}"
10967        );
10968        assert!(
10969            body.contains("uptime:"),
10970            "no uptime — the first thing anyone asks about a container that may \
10971             be restarting: {body}"
10972        );
10973        assert!(body.contains("poller:"), "no scheduler heartbeat: {body}");
10974        assert!(body.contains("polling-paused: no"), "{body}");
10975        assert!(body.contains("backend:"), "{body}");
10976        assert!(body.contains("oauth-runtime:"), "{body}");
10977
10978        // A watermark pause is REPORTED but must not fail the check. A failed
10979        // check DEREGISTERS this machine from the proxy — and it is the only
10980        // machine — so it would turn "feeds are behind" into "the site is down"
10981        // for as long as the disk stays full.
10982        state.runtime_health.set_watermark(true);
10983        state.runtime_health.set_schedulers_enabled(true);
10984        let (status, body) = body_of(state.clone()).await;
10985        assert_eq!(
10986            status,
10987            StatusCode::OK,
10988            "a watermark pause must not fail the liveness check: {body}"
10989        );
10990        assert!(body.contains("polling-paused: yes"), "{body}");
10991        // Schedulers on but no tick yet — and that must not read as "0s ago",
10992        // which is the healthiest possible answer to an unanswered question.
10993        assert!(
10994            body.contains("poller: not-yet-ticked"),
10995            "a never-ticked poller must say so: {body}"
10996        );
10997
10998        // A stale heartbeat is likewise reported, not fatal.
10999        let stale_after = health_tick_stale_secs(configured_poll_tick());
11000        let long_ago = chrono::Utc::now().timestamp() - (stale_after + 60);
11001        state.runtime_health.poll_tick_completed(long_ago);
11002        let (status, body) = body_of(state.clone()).await;
11003        assert_eq!(
11004            status,
11005            StatusCode::OK,
11006            "a stale poller must not 503: {body}"
11007        );
11008        assert!(body.contains("poller: stale"), "{body}");
11009
11010        // **A poller that has never ticked stops being benign.**
11011        //
11012        // In a crash loop with 30 s+ boot cycles the poller never reaches its
11013        // first tick, so `not-yet-ticked` was reported forever and the heartbeat
11014        // could not detect the one failure mode the startup delays were added
11015        // for. It is read against uptime now.
11016        state.runtime_health.poll_tick_completed(0); // reset to "never"
11017        state
11018            .runtime_health
11019            .set_started_at(chrono::Utc::now().timestamp() - (HEALTH_FIRST_TICK_GRACE_SECS + 60));
11020        let (status, body) = body_of(state.clone()).await;
11021        assert_eq!(status, StatusCode::OK);
11022        assert!(
11023            body.contains("poller: stale never-ticked"),
11024            "a poller that never ticked long after boot still reads as benign: {body}"
11025        );
11026
11027        // A closed pool is a real outage: nothing can be served, and a restart is
11028        // the correct response. THIS is what the status code is for.
11029        state.db.close().await;
11030        let (status, body) = body_of(state.clone()).await;
11031        assert_eq!(
11032            status,
11033            StatusCode::SERVICE_UNAVAILABLE,
11034            "an unreachable database must fail the check: {body}"
11035        );
11036        assert!(body.starts_with("FAIL"), "{body}");
11037        // Coarse, not the raw sqlx error: an unauthenticated caller learning
11038        // exactly which failure it hit is an attack-progress oracle, and this
11039        // endpoint is exempt from the origin lock.
11040        assert!(
11041            !body.contains("PoolClosed") && !body.contains("sqlx"),
11042            "health leaked the raw database error to an unauthenticated caller: {body}"
11043        );
11044    }
11045
11046    /// The staleness threshold must track the configured tick.
11047    ///
11048    /// Hardcoded at 15 minutes, an operator who raised
11049    /// `FEATHERREADER_POLL_TICK_SECS` above 900 got a permanent `poller: stale`
11050    /// in the body the deployment docs tell them to alert on.
11051    #[test]
11052    fn the_stale_threshold_follows_the_poll_tick() {
11053        // A fast tick keeps the floor — five 60 s ticks is 5 minutes, and
11054        // alerting that early would fire on any brief hiccup.
11055        assert_eq!(
11056            health_tick_stale_secs(Duration::from_secs(60)),
11057            HEALTH_TICK_STALE_FLOOR_SECS
11058        );
11059        // A slow tick raises it, so a legitimately-configured loop is never
11060        // permanently "stale".
11061        let slow = Duration::from_secs(30 * 60);
11062        assert!(
11063            health_tick_stale_secs(slow) > slow.as_secs() as i64,
11064            "a 30-minute tick must not be stale after one interval"
11065        );
11066        assert_eq!(health_tick_stale_secs(slow), 30 * 60 * 5);
11067        // And it cannot overflow into nonsense on an absurd value.
11068        assert!(health_tick_stale_secs(Duration::from_secs(u64::MAX)) > 0);
11069    }
11070
11071    /// `/stats` must distinguish "nothing is polling" from "polling is fine".
11072    ///
11073    /// `polling_paused` alone rendered "running" for three different states,
11074    /// including the two where nothing polls at all — on the page added to
11075    /// answer exactly that question.
11076    #[tokio::test]
11077    async fn stats_does_not_call_a_stopped_poller_running() {
11078        let state = test_state(&[]).await;
11079        let render = |state: AppState| async move {
11080            let resp = router(state)
11081                .oneshot(
11082                    Request::builder()
11083                        .uri("/stats")
11084                        .body(Body::empty())
11085                        .unwrap(),
11086                )
11087                .await
11088                .unwrap();
11089            assert_eq!(resp.status(), StatusCode::OK);
11090            String::from_utf8(
11091                axum::body::to_bytes(resp.into_body(), usize::MAX)
11092                    .await
11093                    .unwrap()
11094                    .to_vec(),
11095            )
11096            .unwrap()
11097        };
11098
11099        // Schedulers never started: not "running".
11100        let body = render(state.clone()).await;
11101        assert!(
11102            body.contains("the poller is not running on this instance"),
11103            "a disabled poller renders as healthy"
11104        );
11105
11106        // Started, but no tick has finished yet.
11107        state.runtime_health.set_schedulers_enabled(true);
11108        let body = render(state.clone()).await;
11109        assert!(
11110            body.contains("no poll has finished since this instance booted"),
11111            "a poller that has not ticked renders as healthy"
11112        );
11113
11114        // Ticking: running.
11115        state
11116            .runtime_health
11117            .poll_tick_completed(chrono::Utc::now().timestamp());
11118        let body = render(state.clone()).await;
11119        assert!(
11120            body.contains("running"),
11121            "a healthy poller must read as running"
11122        );
11123
11124        // Paused at the watermark still wins over "running".
11125        state.runtime_health.set_watermark(true);
11126        let body = render(state.clone()).await;
11127        assert!(
11128            body.contains("the cache is at its size limit"),
11129            "a watermark pause is hidden once the poller is ticking"
11130        );
11131    }
11132
11133    /// **An UNMEASURED database must not fail the check.**
11134    ///
11135    /// `/health` is the one path exempt from the Cloudflare origin lock and
11136    /// absent from the rate limiter, and `DbProbeGuard` releases its claim on
11137    /// drop WITHOUT recording a verdict — so a cancelled request (a client
11138    /// disconnect is enough) leaves the verdict at "none", and a concurrent
11139    /// caller reads it. Treating that as a failure turned an unauthenticated
11140    /// request into a lever on the only signal the platform acts on. The
11141    /// previous version of this code had the opposite bug and reported `ok` for
11142    /// a database nothing had read; "unknown" is neither.
11143    #[tokio::test]
11144    async fn health_reports_an_unmeasured_database_without_failing() {
11145        use crate::runtime_health::DbProbe;
11146        let state = test_state(&[]).await;
11147
11148        // Hold the probe claim, exactly as an in-flight request would, and never
11149        // record a verdict — the cancelled-request state.
11150        let held = state
11151            .runtime_health
11152            .begin_db_probe()
11153            .unwrap_or_else(|_| panic!("a fresh RuntimeHealth must grant the first claim"));
11154
11155        let resp = router(state.clone())
11156            .oneshot(
11157                Request::builder()
11158                    .uri("/health")
11159                    .body(Body::empty())
11160                    .unwrap(),
11161            )
11162            .await
11163            .unwrap();
11164        let status = resp.status();
11165        let body = String::from_utf8(
11166            axum::body::to_bytes(resp.into_body(), usize::MAX)
11167                .await
11168                .unwrap()
11169                .to_vec(),
11170        )
11171        .unwrap();
11172        drop(held);
11173
11174        assert_eq!(
11175            status,
11176            StatusCode::OK,
11177            "an unmeasured database failed the check, which an unauthenticated \
11178             caller can cause on demand: {body}"
11179        );
11180        assert!(
11181            body.contains("db: unknown"),
11182            "the unmeasured state must still be REPORTED: {body}"
11183        );
11184        assert!(!body.starts_with("FAIL"), "{body}");
11185        // **And it must not read as `ok` either.** `fly.toml` tells operators to
11186        // alert on the BODY for everything the status code ignores, so a first
11187        // line identical to the healthy one makes a monitor keying on `^ok` read
11188        // green in exactly the state this enum exists to surface.
11189        assert!(
11190            !body.starts_with("ok"),
11191            "the unmeasured state is indistinguishable from healthy to a \
11192             body-matching monitor: {body}"
11193        );
11194        assert!(body.starts_with("unknown"), "{body}");
11195
11196        // **A BORROWED failure must 503 too.**
11197        //
11198        // This previously recorded `Failed` and then closed the pool — but
11199        // `record` consumes the guard and releases the claim, so the request won
11200        // it, ran a live probe against the closed pool, and failed on its own.
11201        // The 503 passed for the wrong reason and the borrow path — the whole
11202        // point of the three-state enum on the read side — had no coverage.
11203        //
11204        // Holding the claim forces the borrow, so the recorded verdict is what
11205        // gets reported.
11206        let held = state
11207            .runtime_health
11208            .begin_db_probe()
11209            .unwrap_or_else(|_| panic!("claim"));
11210        state
11211            .runtime_health
11212            .record_for_test(DbProbe::Failed("unavailable".to_string()));
11213        let resp = router(state.clone())
11214            .oneshot(
11215                Request::builder()
11216                    .uri("/health")
11217                    .body(Body::empty())
11218                    .unwrap(),
11219            )
11220            .await
11221            .unwrap();
11222        let status = resp.status();
11223        let body = String::from_utf8(
11224            axum::body::to_bytes(resp.into_body(), usize::MAX)
11225                .await
11226                .unwrap()
11227                .to_vec(),
11228        )
11229        .unwrap();
11230        drop(held);
11231        assert_eq!(
11232            status,
11233            StatusCode::SERVICE_UNAVAILABLE,
11234            "a BORROWED failure verdict must fail the check, not just a freshly \
11235             measured one: {body}"
11236        );
11237        assert!(body.starts_with("FAIL"), "{body}");
11238
11239        state.db.close().await;
11240        let resp = router(state.clone())
11241            .oneshot(
11242                Request::builder()
11243                    .uri("/health")
11244                    .body(Body::empty())
11245                    .unwrap(),
11246            )
11247            .await
11248            .unwrap();
11249        assert_eq!(
11250            resp.status(),
11251            StatusCode::SERVICE_UNAVAILABLE,
11252            "a measured database failure must still fail the check"
11253        );
11254    }
11255
11256    /// **A disconnected client must not be able to cancel the probe.**
11257    ///
11258    /// Axum drops the handler future when a caller goes away. With the probe
11259    /// inline that dropped it mid-flight and released the claim WITHOUT
11260    /// recording a verdict — which let an unauthenticated caller manufacture the
11261    /// no-verdict state on demand and freeze what every other caller, including
11262    /// Fly's own check, reads. The probe runs detached now, so the verdict is
11263    /// recorded whatever happens to the request that started it.
11264    #[tokio::test]
11265    async fn an_abandoned_request_still_records_its_probe() {
11266        use crate::runtime_health::DbProbe;
11267        let state = test_state(&[]).await;
11268        let rh = state.runtime_health.clone();
11269
11270        // Drive /health and abandon it immediately — the disconnect case.
11271        let app = router(state.clone());
11272        let fut = app.oneshot(
11273            Request::builder()
11274                .uri("/health")
11275                .body(Body::empty())
11276                .unwrap(),
11277        );
11278        let handle = tokio::spawn(fut);
11279        handle.abort();
11280        let _ = handle.await;
11281
11282        // The detached probe still completes and publishes a verdict, so the
11283        // claim is free and the next caller gets a MEASURED answer.
11284        for _ in 0..50 {
11285            if rh.begin_db_probe().is_ok() {
11286                break;
11287            }
11288            tokio::time::sleep(Duration::from_millis(20)).await;
11289        }
11290        let resp = router(state.clone())
11291            .oneshot(
11292                Request::builder()
11293                    .uri("/health")
11294                    .body(Body::empty())
11295                    .unwrap(),
11296            )
11297            .await
11298            .unwrap();
11299        let body = String::from_utf8(
11300            axum::body::to_bytes(resp.into_body(), usize::MAX)
11301                .await
11302                .unwrap()
11303                .to_vec(),
11304        )
11305        .unwrap();
11306        assert!(
11307            body.contains("db: ok"),
11308            "after an abandoned request the next caller still reads an \
11309             unmeasured database — the probe was cancelled with it: {body}"
11310        );
11311        // Sanity: the type still distinguishes the three states.
11312        assert_ne!(DbProbe::Unknown, DbProbe::Ok);
11313    }
11314
11315    /// **The probe must read a real page.**
11316    ///
11317    /// `SELECT 1` compiles to `Init/Integer/ResultRow/Halt` — no `OpenRead`, so
11318    /// it never touches a b-tree and returns success against a corrupted
11319    /// database. Asserted by asking SQLite what the statement actually compiles
11320    /// to, so it survives someone "simplifying" the query later.
11321    #[tokio::test]
11322    async fn the_health_probe_opens_a_real_table() {
11323        use sqlx::Row;
11324        let state = test_state(&[]).await;
11325        // `EXPLAIN` lists the VM program; the `opcode` column is the second.
11326        let opcodes = |sql: &'static str| {
11327            let db = state.db.clone();
11328            async move {
11329                sqlx::query(sql)
11330                    .fetch_all(&db)
11331                    .await
11332                    .unwrap()
11333                    .into_iter()
11334                    .map(|r| r.get::<String, _>("opcode"))
11335                    .collect::<Vec<String>>()
11336            }
11337        };
11338
11339        // The statement `health_db_probe` really runs — it is the sole path, so
11340        // there is no second string for the handler to use instead.
11341        let explain: &'static str =
11342            Box::leak(format!("EXPLAIN {HEALTH_DB_PROBE_SQL}").into_boxed_str());
11343        let probe = opcodes(explain).await;
11344        // And the probe itself works against a real schema.
11345        assert!(
11346            health_db_probe(&state.db).await.is_ok(),
11347            "the probe does not run against the real schema",
11348        );
11349        assert!(
11350            probe.iter().any(|op| op == "OpenRead"),
11351            "the health probe reads no page; it cannot detect a broken database: {probe:?}"
11352        );
11353        // And the bare form genuinely does not, which is the whole point.
11354        let bare = opcodes("EXPLAIN SELECT 1").await;
11355        assert!(
11356            !bare.iter().any(|op| op == "OpenRead"),
11357            "premise check failed: bare SELECT 1 now reads a page: {bare:?}"
11358        );
11359    }
11360
11361    /// A fresh instance says "never", not "0" — which would read as "polled
11362    /// just now", the opposite of the truth.
11363    #[test]
11364    fn an_instance_that_has_never_polled_says_so() {
11365        assert_eq!(humanise_ago(None), "never");
11366        assert_eq!(humanise_ago(Some(0)), "0s ago");
11367        assert_eq!(humanise_ago(Some(59)), "59s ago");
11368        assert_eq!(humanise_ago(Some(60)), "1m ago");
11369        assert_eq!(humanise_ago(Some(3600)), "1h 0m ago");
11370        assert_eq!(humanise_ago(Some(11_460)), "3h 11m ago");
11371    }
11372
11373    /// A sidecar mock that answers `/internal/repo` listRecords with one saved
11374    /// record, and anything else with an empty list. Serves repeatedly.
11375    async fn spawn_saved_sidecar(saved_url: &str, saved_title: &str) -> String {
11376        use tokio::io::{AsyncReadExt, AsyncWriteExt};
11377        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
11378        let addr = listener.local_addr().unwrap();
11379        let (url, title) = (saved_url.to_string(), saved_title.to_string());
11380        tokio::spawn(async move {
11381            loop {
11382                let Ok((mut sock, _)) = listener.accept().await else {
11383                    break;
11384                };
11385                let mut buf = vec![0u8; 8192];
11386                let Ok(n) = sock.read(&mut buf).await else {
11387                    continue;
11388                };
11389                let req = String::from_utf8_lossy(&buf[..n]).to_string();
11390                let wants_saved = req.contains("community.lexicon.rss.saved");
11391                let records = if wants_saved {
11392                    serde_json::json!([{
11393                        "uri": "at://did:plc:x/community.lexicon.rss.saved/rk1",
11394                        "cid": "bafy",
11395                        "value": {
11396                            "$type": "community.lexicon.rss.saved",
11397                            "url": url,
11398                            "title": title,
11399                            "createdAt": "2026-01-01T00:00:00Z"
11400                        }
11401                    }])
11402                } else {
11403                    serde_json::json!([])
11404                };
11405                let body = serde_json::json!({
11406                    "ok": true, "data": { "records": records }
11407                })
11408                .to_string();
11409                let resp = format!(
11410                    "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
11411                    body.len(), body
11412                );
11413                let _ = sock.write_all(resp.as_bytes()).await;
11414                let _ = sock.flush().await;
11415            }
11416        });
11417        format!("http://{addr}")
11418    }
11419
11420    /// A sidecar mock serving `n` distinct saved records, none of them cached
11421    /// locally — the shape that exercises the uncached-row append.
11422    async fn spawn_saved_sidecar_many(n: usize, subscribed_feed: &str) -> String {
11423        let feed = subscribed_feed.to_string();
11424        use tokio::io::{AsyncReadExt, AsyncWriteExt};
11425        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
11426        let addr = listener.local_addr().unwrap();
11427        tokio::spawn(async move {
11428            loop {
11429                let Ok((mut sock, _)) = listener.accept().await else {
11430                    break;
11431                };
11432                let mut buf = vec![0u8; 8192];
11433                let Ok(read) = sock.read(&mut buf).await else {
11434                    continue;
11435                };
11436                let req = String::from_utf8_lossy(&buf[..read]).to_string();
11437                let records = if req.contains("community.lexicon.rss.saved") {
11438                    serde_json::Value::Array(
11439                        (0..n)
11440                            .map(|i| {
11441                                serde_json::json!({
11442                                    "uri": format!("at://did:plc:x/community.lexicon.rss.saved/rk{i}"),
11443                                    "cid": "bafy",
11444                                    "value": {
11445                                        "$type": "community.lexicon.rss.saved",
11446                                        "url": format!("https://elsewhere.example/{i}"),
11447                                        "title": format!("Elsewhere {i}"),
11448                                        "createdAt": "2026-01-01T00:00:00Z"
11449                                    }
11450                                })
11451                            })
11452                            .collect(),
11453                    )
11454                } else if req.contains("community.lexicon.rss.subscription") {
11455                    // Without this the handler's `sync_sub_refs` would REPLACE
11456                    // sub_ref with an empty set on every render, and every
11457                    // sub_ref-scoped read — including the cached starred list
11458                    // this test is about — would come back empty.
11459                    serde_json::json!([{
11460                        "uri": "at://did:plc:x/community.lexicon.rss.subscription/sub1",
11461                        "cid": "bafy",
11462                        "value": {
11463                            "$type": "community.lexicon.rss.subscription",
11464                            "url": feed,
11465                            "createdAt": "2026-01-01T00:00:00Z"
11466                        }
11467                    }])
11468                } else {
11469                    serde_json::json!([])
11470                };
11471                let body =
11472                    serde_json::json!({ "ok": true, "data": { "records": records } }).to_string();
11473                let resp = format!(
11474                    "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
11475                    body.len(), body
11476                );
11477                let _ = sock.write_all(resp.as_bytes()).await;
11478                let _ = sock.flush().await;
11479            }
11480        });
11481        format!("http://{addr}")
11482    }
11483
11484    /// **The pager must not advertise a page the clamp cannot reach.**
11485    ///
11486    /// The page clamp is computed from the CACHED total; the uncached PDS rows
11487    /// are appended to the last page rather than paged. Inflating `total` with
11488    /// them made `page_count` and the "Older →" link point one page past the end:
11489    /// requesting it clamped straight back, re-rendered the same last page, and
11490    /// still offered the link. An infinite "next" that never advances.
11491    #[tokio::test]
11492    async fn the_starred_pager_does_not_advertise_an_unreachable_page() {
11493        let did = "did:plc:pagerloop";
11494        let sidecar = spawn_saved_sidecar_many(80, "https://loop.example/feed.xml").await;
11495        let state = test_state_with_sidecar(&[], &sidecar).await;
11496        store::grant_access(&state.db, did, None, "test", None)
11497            .await
11498            .unwrap();
11499        let feed = store::upsert_feed(
11500            &state.db,
11501            &store::NewFeed {
11502                url: "https://loop.example/feed.xml".to_string(),
11503                title: Some("Loop".to_string()),
11504                ..Default::default()
11505            },
11506        )
11507        .await
11508        .unwrap();
11509        // 250 cached starred entries: the last page holds 50, so 50 + 80 > 100
11510        // and the old arithmetic reported a fourth page.
11511        let entries: Vec<store::NewEntry> = (0..250)
11512            .map(|i| store::NewEntry {
11513                guid: format!("s-{i:04}"),
11514                url: Some(format!("https://loop.example/{i}")),
11515                title: Some(format!("Starred {i:04}")),
11516                published: Some(format!("2026-06-{:02}T00:00:00Z", (i % 28) + 1)),
11517                ..Default::default()
11518            })
11519            .collect();
11520        store::insert_entries(&state.db, feed, &entries, 0)
11521            .await
11522            .unwrap();
11523        store::replace_sub_refs(&state.db, did, &[feed])
11524            .await
11525            .unwrap();
11526        for row in store::list_entries(&state.db, did, store::ListView::All, None, 1_000, 0)
11527            .await
11528            .unwrap()
11529        {
11530            store::mark_starred(&state.db, did, row.id, true)
11531                .await
11532                .unwrap();
11533        }
11534
11535        let cookie = session_cookie(&state, did, None);
11536        let app = router(state.clone());
11537        let get = |uri: &str| {
11538            let (app, cookie, uri) = (app.clone(), cookie.clone(), uri.to_string());
11539            async move {
11540                let resp = app
11541                    .oneshot(
11542                        Request::builder()
11543                            .uri(uri)
11544                            .header(header::COOKIE, cookie)
11545                            .body(Body::empty())
11546                            .unwrap(),
11547                    )
11548                    .await
11549                    .unwrap();
11550                assert_eq!(resp.status(), StatusCode::OK);
11551                String::from_utf8(
11552                    axum::body::to_bytes(resp.into_body(), 16 * 1024 * 1024)
11553                        .await
11554                        .unwrap()
11555                        .to_vec(),
11556                )
11557                .unwrap()
11558            }
11559        };
11560
11561        // 250 cached + 80 uncached = 330 rows over 4 pages. The pager and the
11562        // clamp must agree on that, and EVERY page it offers must have content —
11563        // the original bug advertised a fourth page that clamped back to the
11564        // third and re-rendered it, still offering the link.
11565        let p3 = get("/?view=starred&page=3").await;
11566        assert!(
11567            p3.contains("Page 3 of 4"),
11568            "the pager and the clamp disagree on the total: {}",
11569            p3.split("pager-pos")
11570                .nth(1)
11571                .unwrap_or("")
11572                .chars()
11573                .take(120)
11574                .collect::<String>()
11575        );
11576        // Page 3 is the boundary: the last 50 cached rows, then the first 50
11577        // uncached ones.
11578        assert!(
11579            p3.contains("Elsewhere 0"),
11580            "page 3 should start the uncached run"
11581        );
11582        assert_eq!(
11583            p3.matches("<li class=\"entry").count(),
11584            ENTRIES_PER_PAGE as usize,
11585            "the boundary page is not full"
11586        );
11587
11588        // **The heading, which the previous round broke by deleting this.**
11589        //
11590        // `total` includes the uncached records, so the parenthetical is a
11591        // SUBSET of it, not an addition — "330 entries (80 saved elsewhere)".
11592        // The version that said "plus N" double counted once `total` started
11593        // including them, and N had become page-local in the same commit while
11594        // the template stayed put. It shipped because this assertion was deleted
11595        // rather than updated.
11596        {
11597            let body = &p3;
11598            assert!(
11599                body.contains("330 entries"),
11600                "the heading must count the whole sequence: {}",
11601                body.split("content-count")
11602                    .nth(1)
11603                    .unwrap_or("")
11604                    .chars()
11605                    .take(120)
11606                    .collect::<String>()
11607            );
11608            assert!(
11609                body.contains("(80 saved elsewhere)"),
11610                "the heading must say how many of the total the cache cannot show, \
11611                 as a whole-list figure and not a per-page one: {}",
11612                body.split("content-count")
11613                    .nth(1)
11614                    .unwrap_or("")
11615                    .chars()
11616                    .take(120)
11617                    .collect::<String>()
11618            );
11619            assert!(
11620                !body.contains("plus 50") && !body.contains("plus 80"),
11621                "the heading is adding the uncached rows to a total that already \
11622                 includes them"
11623            );
11624        }
11625
11626        let p4 = get("/?view=starred&page=4").await;
11627        assert!(
11628            p4.contains("Page 4 of 4"),
11629            "page 4 was advertised but clamps somewhere else — the unreachable-page bug"
11630        );
11631        assert_eq!(
11632            p4.matches("<li class=\"entry").count(),
11633            30,
11634            "page 4 should hold the remaining 30 uncached records"
11635        );
11636        assert!(
11637            p4.contains("Elsewhere 79"),
11638            "the LAST saved record is unreachable — it can only be removed from here"
11639        );
11640
11641        // No uncached record appears on two pages.
11642        assert!(
11643            !p4.contains("Elsewhere 0"),
11644            "an uncached record was rendered on more than one page"
11645        );
11646        // Page 1 is all cached — and still reports the same whole-list heading,
11647        // because the parenthetical describes the LIST, not the page.
11648        let first = get("/?view=starred").await;
11649        assert!(
11650            first.contains("330 entries") && first.contains("(80 saved elsewhere)"),
11651            "the heading changed between pages; it describes the list, not the page"
11652        );
11653        assert!(
11654            !first.contains("Elsewhere "),
11655            "uncached saved records leaked onto the first page"
11656        );
11657    }
11658
11659    /// **A saved record whose article is not cached here is still shown.**
11660    ///
11661    /// The starred view is built from local `entries`, so before this a record
11662    /// starred in ANOTHER atproto reader — the portability the shared lexicon
11663    /// exists for — was simply invisible. It now renders from the PDS record,
11664    /// visually distinct, linking straight out.
11665    #[tokio::test]
11666    async fn a_saved_record_with_no_cached_entry_is_shown_as_a_link() {
11667        let did = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
11668        let sidecar =
11669            spawn_saved_sidecar("https://elsewhere.example/article", "Starred elsewhere").await;
11670        let mut state = test_state_with_sidecar(&[did], &sidecar).await;
11671        std::sync::Arc::get_mut(&mut state.config).unwrap().dev_did = Some(did.to_string());
11672
11673        let resp = router(state)
11674            .oneshot(
11675                Request::builder()
11676                    .uri("/?view=starred")
11677                    .body(Body::empty())
11678                    .unwrap(),
11679            )
11680            .await
11681            .unwrap();
11682        assert_eq!(resp.status(), StatusCode::OK);
11683        let body = String::from_utf8(
11684            axum::body::to_bytes(resp.into_body(), usize::MAX)
11685                .await
11686                .unwrap()
11687                .to_vec(),
11688        )
11689        .unwrap();
11690
11691        assert!(
11692            body.contains("Starred elsewhere"),
11693            "the saved record was not rendered at all"
11694        );
11695        assert!(
11696            body.contains("entry-uncached"),
11697            "it was not marked as uncached, so it looks like a normal entry"
11698        );
11699        assert!(
11700            body.contains("https://elsewhere.example/article"),
11701            "the row must link straight to the article"
11702        );
11703        assert!(
11704            !body.contains("/entries/0/"),
11705            "an uncached row must not offer entry actions against a nonexistent id"
11706        );
11707    }
11708
11709    /// **A PDS `createdAt` must not be able to panic the starred view.**
11710    ///
11711    /// `display_date` byte-sliced `p[..10]`. Every prior caller passed a
11712    /// timestamp the feed parser produced; the saved-record path passes a bare
11713    /// string off a PDS record, written by whatever client the reader used. A
11714    /// multi-byte value panicked the handler, and with no catch-panic layer the
11715    /// view stayed down until the record was removed — from that same view.
11716    #[test]
11717    fn a_multibyte_timestamp_does_not_panic_the_date_formatter() {
11718        for hostile in [
11719            "日本語日本語日本",
11720            "é",
11721            "",
11722            "2026",
11723            "🙂🙂🙂🙂🙂🙂🙂🙂🙂🙂🙂",
11724        ] {
11725            let out = display_date(Some(hostile));
11726            assert!(out.chars().count() <= 10, "{hostile:?} -> {out:?}");
11727        }
11728        assert_eq!(display_date(Some("2026-01-01T00:00:00Z")), "2026-01-01");
11729        assert_eq!(display_date(None), "");
11730    }
11731
11732    /// Unsaving makes a DPoP-signed PDS round-trip, which is the stated reason
11733    /// its neighbours are limited. It was added as a route and not added here.
11734    #[test]
11735    fn the_unsave_route_is_rate_limited() {
11736        use axum::http::Method;
11737        assert!(is_rate_limited_path("/saved/3abc/delete", &Method::POST));
11738        // And the neighbours still are.
11739        assert!(is_rate_limited_path("/entries/1/star", &Method::POST));
11740    }
11741
11742    /// **The probe detects a broken database — asserted through `/health`
11743    /// itself, not through a string.**
11744    ///
11745    /// A named constant did not bind the handler: it stayed free to call
11746    /// `query_scalar` with a different literal, so degrading the real probe to
11747    /// `SELECT 1` shipped green twice over. This drops the table the probe reads
11748    /// and asserts the endpoint stops saying `ok` — behaviour no substituted SQL
11749    /// can fake, because `SELECT 1` still succeeds against a wrecked schema.
11750    #[tokio::test]
11751    async fn health_reports_a_broken_database() {
11752        let state = test_state(&[]).await;
11753        // Sanity: healthy first, so the assertion below is about the damage.
11754        assert!(
11755            health_db_probe(&state.db).await.is_ok(),
11756            "the fixture was not healthy to begin with",
11757        );
11758
11759        sqlx::query("DROP TABLE feeds")
11760            .execute(&state.db)
11761            .await
11762            .unwrap();
11763
11764        assert!(
11765            health_db_probe(&state.db).await.is_err(),
11766            "the probe reported success against a database missing the table it \
11767             claims to read; `SELECT 1` would do exactly this",
11768        );
11769
11770        let resp = router(state)
11771            .oneshot(
11772                Request::builder()
11773                    .uri("/health")
11774                    .body(Body::empty())
11775                    .unwrap(),
11776            )
11777            .await
11778            .unwrap();
11779        let body = String::from_utf8(
11780            axum::body::to_bytes(resp.into_body(), usize::MAX)
11781                .await
11782                .unwrap()
11783                .to_vec(),
11784        )
11785        .unwrap();
11786        // The documented contract: the FIRST token is the state.
11787        assert!(
11788            body.starts_with("FAIL"),
11789            "/health did not report FAIL for a broken database: {body}",
11790        );
11791        assert!(
11792            !body.contains("db: ok"),
11793            "/health still called the database ok: {body}",
11794        );
11795    }
11796
11797    /// A sidecar mock for the OPML export: serves one subscription and one
11798    /// folder, except for the collection named in `fail_on`, which answers
11799    /// `500` — the shape a refused (short or unreadable) walk takes at this
11800    /// boundary.
11801    async fn spawn_export_sidecar(fail_on: Option<&'static str>) -> String {
11802        use tokio::io::{AsyncReadExt, AsyncWriteExt};
11803        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
11804        let addr = listener.local_addr().unwrap();
11805        tokio::spawn(async move {
11806            loop {
11807                let Ok((mut sock, _)) = listener.accept().await else {
11808                    break;
11809                };
11810                let mut buf = vec![0u8; 8192];
11811                let Ok(n) = sock.read(&mut buf).await else {
11812                    continue;
11813                };
11814                let req = String::from_utf8_lossy(&buf[..n]).to_string();
11815                let wants = |c: &str| req.contains(c);
11816                if fail_on.is_some_and(wants) {
11817                    let body = r#"{"ok":false,"error":"ShortList"}"#;
11818                    let resp = format!(
11819                        "HTTP/1.1 500 Internal Server Error\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
11820                        body.len(),
11821                        body
11822                    );
11823                    let _ = sock.write_all(resp.as_bytes()).await;
11824                    let _ = sock.flush().await;
11825                    continue;
11826                }
11827                let records = if wants(crate::lexicon::nsid::SUBSCRIPTION) {
11828                    serde_json::json!([{
11829                        "uri": "at://did:plc:exporter/community.lexicon.rss.subscription/sub1",
11830                        "cid": "bafy",
11831                        "value": {
11832                            "$type": crate::lexicon::nsid::SUBSCRIPTION,
11833                            "url": "https://kept.example/feed.xml",
11834                            "title": "Kept",
11835                            // Inside the folder, so the healthy export has to
11836                            // carry BOTH walks' results: an exporter that lost
11837                            // the folder list would flatten this outline out of
11838                            // its group with nothing else changing.
11839                            "folder": "at://did:plc:exporter/community.lexicon.rss.folder/fold1",
11840                            "createdAt": "2026-01-01T00:00:00Z"
11841                        }
11842                    }])
11843                } else if wants(crate::lexicon::nsid::FOLDER) {
11844                    serde_json::json!([{
11845                        "uri": "at://did:plc:exporter/community.lexicon.rss.folder/fold1",
11846                        "cid": "bafy",
11847                        "value": {
11848                            "$type": crate::lexicon::nsid::FOLDER,
11849                            "name": "Kept folder",
11850                            "createdAt": "2026-01-01T00:00:00Z"
11851                        }
11852                    }])
11853                } else {
11854                    serde_json::json!([])
11855                };
11856                let body =
11857                    serde_json::json!({ "ok": true, "data": { "records": records } }).to_string();
11858                let resp = format!(
11859                    "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
11860                    body.len(),
11861                    body
11862                );
11863                let _ = sock.write_all(resp.as_bytes()).await;
11864                let _ = sock.flush().await;
11865            }
11866        });
11867        format!("http://{addr}")
11868    }
11869
11870    /// `GET /opml/export` against the mock, returning `(status, headers, body)`.
11871    async fn export_opml_response(
11872        fail_on: Option<&'static str>,
11873    ) -> (StatusCode, HeaderMap, String) {
11874        let did = "did:plc:exporter";
11875        let sidecar = spawn_export_sidecar(fail_on).await;
11876        let state = test_state_with_sidecar(&[did], &sidecar).await;
11877        let cookie = session_cookie(&state, did, None);
11878        let resp = router(state)
11879            .oneshot(
11880                Request::builder()
11881                    .uri("/opml/export")
11882                    .header(header::COOKIE, cookie)
11883                    .body(Body::empty())
11884                    .unwrap(),
11885            )
11886            .await
11887            .unwrap();
11888        let status = resp.status();
11889        let headers = resp.headers().clone();
11890        let body = String::from_utf8_lossy(
11891            &axum::body::to_bytes(resp.into_body(), usize::MAX)
11892                .await
11893                .unwrap(),
11894        )
11895        .to_string();
11896        (status, headers, body)
11897    }
11898
11899    /// **An empty export is worse than no export, and this is the caller that
11900    /// used to produce one.**
11901    ///
11902    /// `export_opml` read both walks through `unwrap_or_default()`. Now that a
11903    /// truncated walk refuses instead of returning a short list, that turned the
11904    /// refusal into `200 OK` carrying a zero-feed
11905    /// `featherreader-subscriptions.opml` — a blank backup handed over at exactly
11906    /// the moment a locked-out reader reached for one, and the changelog points
11907    /// them at this route as the recovery path.
11908    ///
11909    /// Asserts the three things a reader can actually observe: no success status,
11910    /// no download offered, and no OPML document in the body.
11911    #[tokio::test]
11912    async fn an_export_that_cannot_read_the_subscriptions_serves_no_opml() {
11913        let (status, headers, body) =
11914            export_opml_response(Some(crate::lexicon::nsid::SUBSCRIPTION)).await;
11915
11916        assert_ne!(
11917            status,
11918            StatusCode::OK,
11919            "a failed subscription walk answered 200: {body}",
11920        );
11921        assert!(
11922            !headers.contains_key(header::CONTENT_DISPOSITION),
11923            "a failed subscription walk still offered a download: {headers:?}",
11924        );
11925        assert!(
11926            !body.contains("<opml"),
11927            "a failed subscription walk still served an OPML document: {body}",
11928        );
11929    }
11930
11931    /// The folders half of the same hole. The two walks are separate calls, and
11932    /// fixing only the first leaves an export that silently loses every folder —
11933    /// a flat list that reimports as one, with no sign anything was lost.
11934    #[tokio::test]
11935    async fn an_export_that_cannot_read_the_folders_serves_no_opml() {
11936        let (status, headers, body) =
11937            export_opml_response(Some(crate::lexicon::nsid::FOLDER)).await;
11938
11939        assert_ne!(
11940            status,
11941            StatusCode::OK,
11942            "a failed folder walk answered 200: {body}",
11943        );
11944        assert!(
11945            !headers.contains_key(header::CONTENT_DISPOSITION),
11946            "a failed folder walk still offered a download: {headers:?}",
11947        );
11948        assert!(
11949            !body.contains("<opml"),
11950            "a failed folder walk still served an OPML document: {body}",
11951        );
11952    }
11953
11954    /// The other direction, without which "refuse everything" would pass both
11955    /// tests above: a healthy read still serves the file, with the feed in it.
11956    #[tokio::test]
11957    async fn a_healthy_export_serves_the_subscriptions_as_a_download() {
11958        let (status, headers, body) = export_opml_response(None).await;
11959
11960        assert_eq!(
11961            status,
11962            StatusCode::OK,
11963            "a healthy export did not answer 200"
11964        );
11965        assert_eq!(
11966            headers
11967                .get(header::CONTENT_DISPOSITION)
11968                .and_then(|v| v.to_str().ok()),
11969            Some("attachment; filename=\"featherreader-subscriptions.opml\""),
11970            "a healthy export did not offer the download",
11971        );
11972        assert!(
11973            body.contains("https://kept.example/feed.xml"),
11974            "the exported OPML lost the subscription: {body}",
11975        );
11976        assert!(
11977            body.contains("Kept folder"),
11978            "the exported OPML lost the folder: {body}",
11979        );
11980    }
11981}