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        standard_site: state.config.standard_site,
956    })
957}
958
959/// `POST /saved/:rkey/delete` — remove a saved record that has no local entry.
960///
961/// The normal star toggle is keyed on an entry id, which a PDS-only saved row
962/// does not have. This deletes the record straight from the repo by its rkey,
963/// and then clears any LOCAL star for the same article.
964///
965/// That second step is not belt-and-braces. "Has no local entry" is how the
966/// starred view classifies a record, and it decides that through `sub_ref` — so
967/// an article that really is cached, and really is starred, lands here whenever
968/// the reader has unsubscribed from its feed. Deleting only the record left
969/// `entry_state.starred = 1` behind: invisible, because the starred list is
970/// `sub_ref`-scoped too, until a resubscribe brought the star back with nothing
971/// in the PDS backing it. A reader who clicks "remove" gets it removed from both
972/// places it lives.
973async fn unsave_record(
974    State(state): State<AppState>,
975    headers: HeaderMap,
976    Path(rkey): Path<String>,
977) -> Response {
978    let Some(did) = current_did(&state, &headers).await else {
979        return (StatusCode::UNAUTHORIZED, "sign in first\n").into_response();
980    };
981
982    // Read the record's identity BEFORE deleting it — afterwards there is
983    // nothing left to learn it from. Best-effort: a failure here must not block
984    // the deletion the reader actually asked for, so it degrades to the old
985    // behaviour (record gone, local star possibly stale) and says so.
986    let identity = match state.repo().list_saved(&did).await {
987        Ok(records) => records
988            .into_iter()
989            .find(|(k, _)| *k == rkey)
990            .map(|(_, rec)| (rec.url, rec.entry_id)),
991        Err(err) => {
992            warn!(%err, %did, %rkey, "could not read the saved record before deleting it; \
993                                      a local star for the same article may survive");
994            None
995        }
996    };
997
998    match state.repo().remove_saved(&did, &rkey).await {
999        Ok(()) => info!(%did, %rkey, "removed a saved record with no cached entry"),
1000        Err(err) => {
1001            warn!(%err, %did, %rkey, "could not remove the saved record");
1002            return (StatusCode::BAD_GATEWAY, "could not remove that item\n").into_response();
1003        }
1004    }
1005
1006    // Deliberately AFTER the delete: the PDS is the source of truth for what was
1007    // saved, so clearing the local star before knowing the record is gone would
1008    // be the desync in the other direction.
1009    if let Some((url, guid)) = identity {
1010        match store::clear_star_by_identity(&state.db, &did, Some(&url), guid.as_deref()).await {
1011            Ok(0) => {}
1012            Ok(n) => {
1013                info!(%did, %rkey, cleared = n, "cleared the local star for an unsaved record")
1014            }
1015            Err(err) => warn!(%err, %did, %rkey, "could not clear the local star after unsaving"),
1016        }
1017    }
1018    // htmx swaps the row out; a plain form post goes back to the starred list.
1019    if is_htmx(&headers) {
1020        return (StatusCode::OK, "").into_response();
1021    }
1022    Redirect::to("/?view=starred").into_response()
1023}
1024
1025/// What the poller is doing, as one word for `/stats`.
1026///
1027/// **Parity with `/health` is the point.** `polling_paused` alone reported
1028/// "running" for three different states including the two where nothing polls,
1029/// on the page added to answer exactly that. The first attempt at fixing it
1030/// added `off` and `starting` and claimed parity — but left out `stale`, so a
1031/// poll loop that ticked once at boot and then WEDGED still read as running.
1032/// That is the wedged-loop case `/health`'s heartbeat exists for, and the
1033/// original finding's exact shape surviving its own fix.
1034///
1035/// Shares the staleness threshold with `/health` rather than picking its own, so
1036/// the two pages cannot disagree about what "stale" means.
1037fn fetching_state(rh: &crate::runtime_health::RuntimeHealth, now_unix: i64) -> &'static str {
1038    if !rh.schedulers_enabled() {
1039        return "off";
1040    }
1041    // Checked before the pause: a wedged poller cannot clear a pause either, so
1042    // reporting "paused" would name the symptom and hide the cause.
1043    match rh.secs_since_poll_tick(now_unix) {
1044        None => {
1045            // Never ticked. Benign at boot, a dead loop long after — read
1046            // against uptime, exactly as `/health` does.
1047            match rh.uptime_secs(now_unix) {
1048                Some(up) if up > HEALTH_FIRST_TICK_GRACE_SECS => "stale",
1049                _ => "starting",
1050            }
1051        }
1052        Some(secs) if secs > health_tick_stale_secs(configured_poll_tick()) => "stale",
1053        _ if rh.watermark_paused() => "paused",
1054        _ => "running",
1055    }
1056}
1057
1058/// `GET /stats` — public poll health.
1059async fn stats(State(state): State<AppState>) -> Response {
1060    let now = chrono::Utc::now();
1061    let health = match store::poll_health(
1062        &state.db,
1063        &now.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
1064        &(now - chrono::Duration::hours(1)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
1065    )
1066    .await
1067    {
1068        Ok(health) => health,
1069        Err(err) => {
1070            warn!(%err, "could not compute poll health");
1071            return (StatusCode::INTERNAL_SERVER_ERROR, "stats unavailable\n").into_response();
1072        }
1073    };
1074
1075    // Percentage of a zero-feed instance is 100, not a divide-by-zero: a fresh
1076    // instance is not behind on anything.
1077    let polled_pct = if health.feeds_tracked == 0 {
1078        100
1079    } else {
1080        health.polled_last_hour * 100 / health.feeds_tracked
1081    };
1082
1083    render(&StatsTemplate {
1084        version: VERSION,
1085        repo_url: REPO_URL,
1086        kofi_url: KOFI_URL,
1087        feeds_tracked: health.feeds_tracked,
1088        polled_last_hour: health.polled_last_hour,
1089        polled_pct,
1090        overdue: health.overdue,
1091        last_poll: humanise_ago(health.last_poll_secs_ago),
1092        oldest_poll: if health.never_polled > 0 {
1093            "never".to_string()
1094        } else {
1095            humanise_ago(health.oldest_poll_secs_ago)
1096        },
1097        never_polled: health.never_polled,
1098        poll_interval_mins: state.config.poll_interval.as_secs() as i64 / 60,
1099        // **The two states that actually stop feeds updating.**
1100        //
1101        // Neither was visible anywhere. `overdue` and `polled_last_hour` move in
1102        // both and distinguish neither — and `overdue` moves the WRONG WAY for
1103        // backoff, since backoff is applied by pushing `next_poll` forward, so a
1104        // feed failing every fetch drops out of the backlog and makes the page
1105        // read healthier. Both of these are machine facts with no per-feed
1106        // detail, so they sit inside the page's stated contract.
1107        in_backoff: health.in_backoff,
1108        badly_broken: health.badly_broken,
1109        failure_kinds: health.failure_kinds,
1110        fetching: fetching_state(&state.runtime_health, now.timestamp()),
1111    })
1112}
1113
1114/// "3h 11m ago", or "never" when there has been no poll at all.
1115///
1116/// `None` must not render as `0` — on a fresh instance that would read as
1117/// "polled just now", which is the opposite of the truth.
1118fn humanise_ago(secs: Option<i64>) -> String {
1119    let Some(secs) = secs else {
1120        return "never".to_string();
1121    };
1122    match secs {
1123        s if s < 60 => format!("{s}s ago"),
1124        s if s < 3600 => format!("{}m ago", s / 60),
1125        s => format!("{}h {}m ago", s / 3600, (s % 3600) / 60),
1126    }
1127}
1128
1129/// The `/about` adoption line's data, or `None` (no successful probe yet, an
1130/// observation of zero, or a store failure).
1131///
1132/// A read failure degrades to `None` plus a `warn!` rather than propagating: the
1133/// probe is never allowed to affect the reader, and that rule applies at the
1134/// display end too — a locked or corrupt DB costs the About page one log line,
1135/// not a 500.
1136async fn adoption_line(state: &AppState) -> Option<AdoptionLine> {
1137    match store::latest_network_stat(&state.db, store::ADOPTION_STAT_KEY).await {
1138        // A legitimate zero renders nothing rather than a sad "0 accounts".
1139        Ok(Some(stat)) if stat.value > 0 => Some(AdoptionLine {
1140            repos: stat.value,
1141            truncated: stat.truncated,
1142            observed_on: stat
1143                .observed_at
1144                .split('T')
1145                .next()
1146                .unwrap_or_default()
1147                .to_string(),
1148        }),
1149        Ok(_) => None,
1150        Err(err) => {
1151            warn!(%err, "about: adoption stat read failed; omitting the line");
1152            None
1153        }
1154    }
1155}
1156
1157/// `GET /privacy` — the plain-language privacy page: no account/tracking, data
1158/// lives in the user's PDS, what the server caches, and the session-token
1159/// handling. A static render; readable whether or not a session exists.
1160async fn privacy() -> Response {
1161    render(&PrivacyTemplate {
1162        version: VERSION,
1163        repo_url: REPO_URL,
1164        kofi_url: KOFI_URL,
1165    })
1166}
1167
1168/// `GET /terms` — the terms of use: the experimental / as-is disclaimer,
1169/// acceptable use, the AGPL/self-host note, and the liability limitation. A
1170/// static render; readable whether or not a session exists.
1171async fn terms() -> Response {
1172    render(&TermsTemplate {
1173        version: VERSION,
1174        repo_url: REPO_URL,
1175        kofi_url: KOFI_URL,
1176    })
1177}
1178
1179// ---------------------------------------------------------------------------
1180// View models
1181// ---------------------------------------------------------------------------
1182
1183/// A feed as shown in the sidebar (title + its unread count + a stable scope key
1184/// and the PDS subscription rkey for management actions).
1185struct FeedView {
1186    /// PDS subscription rkey — addresses the record for rename/unsubscribe.
1187    rkey: String,
1188    /// Canonical feed URL — the sidebar filter key (`?feed=<url>`).
1189    url: String,
1190    title: String,
1191    unread: i64,
1192    /// Whether this feed is the currently-selected scope.
1193    selected: bool,
1194    /// The feed's current folder `at://` URI (from its subscription record), or
1195    /// `None` if un-foldered. Drives the pre-selected `<option>` in the manage
1196    /// rename row so an untouched folder dropdown does not silently un-folder the
1197    /// feed on save.
1198    folder: Option<String>,
1199}
1200
1201/// A folder grouping in the sidebar, sourced from the PDS `folder` records.
1202struct FolderView {
1203    /// PDS folder rkey — addresses the record for rename/delete.
1204    rkey: String,
1205    /// The folder's `at://` URI — the sidebar filter key (`?folder=<uri>`).
1206    uri: String,
1207    name: String,
1208    feeds: Vec<FeedView>,
1209    /// Whether this folder is the currently-selected scope.
1210    selected: bool,
1211}
1212
1213/// One entry as shown in the article list / after an htmx swap.
1214struct EntryRow {
1215    id: i64,
1216    title: String,
1217    feed_title: String,
1218    published: String,
1219    read: bool,
1220    starred: bool,
1221    /// The reader link href, already carrying the scope/view query so opening an
1222    /// entry and paging back stays within the list it came from.
1223    link: SafeLink,
1224    /// Whether the article itself is in this instance's cache.
1225    ///
1226    /// `false` for a saved record that exists in the reader's PDS but whose
1227    /// entry was never cached here — starred in another atproto reader, or
1228    /// starred here and since evicted. There is no local row, so the row has no
1229    /// usable `id`: it links straight out to the article and carries no
1230    /// mark-read control, because there is nothing local to mark.
1231    cached: bool,
1232    /// The PDS record key, for un-saving a row that has no local entry.
1233    rkey: String,
1234}
1235
1236/// A folder as an option in the "move feed to folder" select.
1237struct FolderOption {
1238    uri: String,
1239    name: String,
1240}
1241
1242/// The shared navigation "rail" model: the same DOM element is the
1243/// mobile drawer and the desktop sidebar, so every chrome page (list / reader /
1244/// manage) renders it from this one struct. Feed management lives on `/manage`,
1245/// not here — the rail is navigation only.
1246struct Nav {
1247    /// `@handle` for the identity chip (falls back to the DID's tail).
1248    handle: String,
1249    /// Two-letter avatar initials for the identity chip.
1250    avatar: String,
1251    /// The active filter: `"unread" | "all" | "starred"` (drives `aria-current`).
1252    view: String,
1253    /// The scope query suffix (`feed=…` / `folder=…`) carried onto filter links,
1254    /// empty for the unscoped "everything" views.
1255    scope_qs: String,
1256    /// Folders (each with its feeds) then un-foldered feeds, for the rail lists.
1257    /// Per-feed `selected` flags drive the rail's feed `aria-current`.
1258    folders: Vec<FolderView>,
1259    loose_feeds: Vec<FeedView>,
1260    /// Whether the "Manage feeds" rail tool is the current page.
1261    manage_active: bool,
1262}
1263
1264/// The subscribe input's `pattern` when standard.site is on, and the input is
1265/// `type="text"` (see `templates/manage.html`). It keeps the browser asking for
1266/// a scheme, as `type="url"` did, while admitting `at://`. Matched in any case,
1267/// because the handler canonicalises the scheme. Browsers compile `pattern`
1268/// with the `v` flag and anchor it at both ends. Unlike `type="url"`, a text
1269/// input does not strip surrounding whitespace before checking, so the pattern
1270/// allows it: a URL pasted with a leading space is common, and the handler
1271/// trims it.
1272pub(crate) const FEED_URL_PATTERN: &str = "\\s*(?:[Hh][Tt][Tt][Pp][Ss]?|[Aa][Tt])://.+";
1273
1274/// The reader index (`GET /`).
1275#[derive(Template)]
1276#[template(path = "index.html")]
1277struct IndexTemplate {
1278    version: &'static str,
1279    repo_url: &'static str,
1280    kofi_url: &'static str,
1281    flash: String,
1282    /// Shown as `role="alert"` when the subscription list is the cached one
1283    /// because the PDS listing failed; empty otherwise.
1284    alert: String,
1285    /// The shared rail (drawer + desktop sidebar) navigation model.
1286    nav: Nav,
1287    /// The article list for the selected scope + view.
1288    entries: Vec<EntryRow>,
1289    /// The list heading (the selected view/feed/folder name).
1290    heading: String,
1291    /// Whether a feed scope is active (enables per-feed mark-all-read).
1292    feed_scope: Option<String>,
1293    /// Total CACHED entries in this scope + view across ALL pages. The count used
1294    /// to be `entries.len()`, which was the same number only because the list was
1295    /// unpaged — the thing this change exists to stop.
1296    ///
1297    /// The pager is derived from this, so it must not include the uncached PDS
1298    /// rows below: they are appended to the last page rather than paged, and
1299    /// counting them here advertised a page the clamp could never reach.
1300    total: i64,
1301    /// How many of `total` are PDS saved records the cache cannot show.
1302    ///
1303    /// A subset of `total`, not an addition to it — the heading says "N entries
1304    /// (M saved elsewhere)". An earlier version rendered "N entries, plus M",
1305    /// which double counted once `total` started including them, against an M
1306    /// that had become page-local in the same commit while the template stayed
1307    /// put.
1308    uncached_total: i64,
1309    /// 1-based current page.
1310    page: i64,
1311    /// Total pages, at least 1 (an empty list is page 1 of 1).
1312    page_count: i64,
1313    /// Link to the previous (newer) page, or `None` on the first.
1314    prev_href: Option<String>,
1315    /// Link to the next (older) page, or `None` on the last.
1316    next_href: Option<String>,
1317}
1318
1319/// The feed-management page (`GET /manage`) — subscribe / your-feeds / OPML.
1320#[derive(Template)]
1321#[template(path = "manage.html")]
1322struct ManageTemplate {
1323    version: &'static str,
1324    repo_url: &'static str,
1325    kofi_url: &'static str,
1326    flash: String,
1327    /// See [`IndexTemplate::alert`].
1328    alert: String,
1329    nav: Nav,
1330    /// All folders as move-targets for the subscribe folder select.
1331    folder_options: Vec<FolderOption>,
1332    /// Folders (each with feeds) + loose feeds, for the "Your feeds" list.
1333    folders: Vec<FolderView>,
1334    loose_feeds: Vec<FeedView>,
1335    /// `Config::standard_site`. With it on, the subscribe form says a
1336    /// `site.standard.publication` URI is accepted and its input drops
1337    /// `type="url"`, whose browser validation rejects the DID form. With it off
1338    /// `add_subscription` refuses every `at://` paste, so the form must not
1339    /// advertise one.
1340    standard_site: bool,
1341}
1342
1343/// The optional one-line adoption fact at the bottom of `/about`
1344/// (`design/NETWORK-SPEC.md` §4.4). `None` whenever the display flag is off, no
1345/// probe has succeeded yet, or the read failed — the line then simply does not
1346/// render.
1347struct AdoptionLine {
1348    /// Repos a relay has indexed as holding the subscription collection.
1349    repos: i64,
1350    /// The probe hit its page cap, so the copy must say "at least".
1351    truncated: bool,
1352    /// Observation date, `YYYY-MM-DD` (UTC), sliced from the stored RFC3339 stamp.
1353    observed_on: String,
1354}
1355
1356/// The public-experiment `/about` page — disclaimer + OSS pitch + tip link, plus
1357/// the optional adoption line.
1358#[derive(Template)]
1359#[template(path = "about.html")]
1360struct AboutTemplate {
1361    version: &'static str,
1362    repo_url: &'static str,
1363    kofi_url: &'static str,
1364    adoption: Option<AdoptionLine>,
1365    /// `Config::standard_site`: whether the publications section may tell the
1366    /// reader how to subscribe to one here. See [`ManageTemplate::standard_site`].
1367    standard_site: bool,
1368}
1369
1370/// The public `/stats` page — is the poller keeping up?
1371///
1372/// Aggregate only, deliberately. It is published to anyone, so it carries no
1373/// user counts and no per-feed detail: a reader does not need to know how many
1374/// people use an instance or which feeds are failing. What it does answer is the
1375/// question that decides whether an instance can take more readers — whether the
1376/// poller is servicing the feeds it already has.
1377///
1378/// The counts below are aggregate machine facts, which is why they fit that
1379/// contract: "12 feeds are in backoff" names no feed and no reader, while
1380/// answering the question the page was previously unable to answer at all.
1381#[derive(Template)]
1382#[template(path = "stats.html")]
1383struct StatsTemplate {
1384    version: &'static str,
1385    repo_url: &'static str,
1386    kofi_url: &'static str,
1387    feeds_tracked: i64,
1388    polled_last_hour: i64,
1389    polled_pct: i64,
1390    overdue: i64,
1391    last_poll: String,
1392    oldest_poll: String,
1393    never_polled: i64,
1394    poll_interval_mins: i64,
1395    /// Feeds in error backoff. Invisible before, and excluded from `overdue`.
1396    in_backoff: i64,
1397    /// Of those, the ones retried hours apart rather than minutes. **Not
1398    /// "effectively dead"** — see `store::BADLY_BROKEN_ERRORS`; they recover on
1399    /// their next successful poll, and most of this instance's did.
1400    badly_broken: i64,
1401    /// Failing feeds by cause, descending — counts only, never which feed.
1402    failure_kinds: Vec<(String, i64)>,
1403    /// What the poller is actually doing: `running`, `paused` (at the size
1404    /// watermark), `starting` (no tick completed yet) or `off` (schedulers
1405    /// disabled). Three of those four used to render as "running".
1406    fetching: &'static str,
1407}
1408
1409/// The public `/privacy` page — what the server holds vs. what lives in the
1410/// user's PDS. Carries the same `repo_url`/`kofi_url`/`version` the shared
1411/// footer include needs.
1412#[derive(Template)]
1413#[template(path = "privacy.html")]
1414struct PrivacyTemplate {
1415    version: &'static str,
1416    repo_url: &'static str,
1417    kofi_url: &'static str,
1418}
1419
1420/// The public `/terms` page — the as-is / no-warranty terms of use. Carries the
1421/// same fields the shared footer include needs.
1422#[derive(Template)]
1423#[template(path = "terms.html")]
1424struct TermsTemplate {
1425    version: &'static str,
1426    repo_url: &'static str,
1427    kofi_url: &'static str,
1428}
1429
1430/// The signed-out landing page (`GET /` with no session) — the public front
1431/// door at feather-reader.com. A static render, no session required.
1432#[derive(Template)]
1433#[template(path = "landing.html")]
1434struct LandingTemplate {
1435    version: &'static str,
1436    repo_url: &'static str,
1437    crates_url: &'static str,
1438    kofi_url: &'static str,
1439    /// `Config::standard_site`: whether the publications point may tell a
1440    /// visitor how to subscribe to one here. See [`ManageTemplate::standard_site`].
1441    standard_site: bool,
1442}
1443
1444/// The single-entry reader view (`GET /entries/:id`).
1445#[derive(Template)]
1446#[template(path = "entry.html")]
1447struct EntryTemplate {
1448    version: &'static str,
1449    repo_url: &'static str,
1450    kofi_url: &'static str,
1451    nav: Nav,
1452    id: i64,
1453    title: String,
1454    feed_title: String,
1455    author: Option<String>,
1456    published: String,
1457    /// The entry's own link, for `entry.html`'s two `href`s.
1458    ///
1459    /// `Option<SafeLink>`, not `Option<String>`: the column it comes from holds
1460    /// a remote feed's `<link>`. Ingest scheme-checks it, but that guard is a
1461    /// long way from the `href` and holds only while every future writer to
1462    /// `entries.url` remembers to go through `feed.rs` — the same procedural
1463    /// defence that, on the saved-record row, turned out to be deletable with
1464    /// all 679 tests still green. `None` is the refusal: the template's
1465    /// no-URL branch already renders a disabled open-original button.
1466    url: Option<SafeLink>,
1467    content_html: Option<String>,
1468    read: bool,
1469    starred: bool,
1470    /// The query string to carry the reading context back to the list.
1471    back_qs: String,
1472    /// Prev/next entry ids within the current list, for keyboard/paging nav.
1473    prev_id: Option<i64>,
1474    next_id: Option<i64>,
1475    /// Rendered inline (not an out-of-band swap fragment): always `false` here.
1476    oob: bool,
1477}
1478
1479/// The htmx swap fragment for a single entry row (`entry_row.html`).
1480#[derive(Template)]
1481#[template(path = "entry_row.html")]
1482struct EntryRowTemplate {
1483    e: EntryRow,
1484}
1485
1486/// The reader's action-bar fragment (`entry_actionbar.html`) returned as an
1487/// out-of-band swap after a mark-read / star toggle FROM THE READER, so the
1488/// button's hidden value + `aria-pressed` update in place (the reader `<li>`
1489/// isn't in the DOM to swap, unlike the list view's `entry_row.html`).
1490#[derive(Template)]
1491#[template(path = "entry_actionbar.html")]
1492struct EntryActionBarTemplate {
1493    id: i64,
1494    read: bool,
1495    starred: bool,
1496    /// Emit the `hx-swap-oob` attribute: `true` for the handler's OOB response.
1497    oob: bool,
1498}
1499
1500/// The login stub (`GET /login`).
1501#[derive(Template)]
1502#[template(path = "login.html")]
1503struct LoginTemplate {
1504    repo_url: &'static str,
1505    error: String,
1506    /// A neutral/success banner (e.g. the post-delete "signed out" confirmation),
1507    /// distinct from `error`. Empty renders nothing.
1508    flash: String,
1509}
1510
1511/// The closed-beta invite-redeem page (`GET /beta/redeem`).
1512#[derive(Template)]
1513#[template(path = "beta_redeem.html")]
1514struct BetaRedeemTemplate {
1515    repo_url: &'static str,
1516    error: String,
1517    /// When true the seat cap is full: hide the form and show the "capacity
1518    /// full — try self-hosting" message instead.
1519    capacity_full: bool,
1520}
1521
1522// ---------------------------------------------------------------------------
1523// Rendering + error helpers
1524// ---------------------------------------------------------------------------
1525
1526/// Render an askama template into an HTML response, mapping a render failure to
1527/// a `500` rather than panicking (no `unwrap` in the request path).
1528fn render<T: Template>(tmpl: &T) -> Response {
1529    match tmpl.render() {
1530        Ok(body) => Html(body).into_response(),
1531        Err(err) => {
1532            warn!(%err, "template render failed");
1533            (StatusCode::INTERNAL_SERVER_ERROR, "template render error").into_response()
1534        }
1535    }
1536}
1537
1538/// A minimal web error type so handlers can `?`-propagate `anyhow` failures and
1539/// still return an `impl IntoResponse`. Renders as a `500` with a short message
1540/// by default; a handler may override the status (e.g. `413` for an over-cap
1541/// upload) via [`WebError::with_status`].
1542struct WebError {
1543    err: anyhow::Error,
1544    status: StatusCode,
1545}
1546
1547impl<E: Into<anyhow::Error>> From<E> for WebError {
1548    fn from(err: E) -> Self {
1549        WebError {
1550            err: err.into(),
1551            status: StatusCode::INTERNAL_SERVER_ERROR,
1552        }
1553    }
1554}
1555
1556impl WebError {
1557    /// Attach an explicit HTTP status to render instead of the default `500`.
1558    fn with_status(err: impl Into<anyhow::Error>, status: StatusCode) -> Self {
1559        WebError {
1560            err: err.into(),
1561            status,
1562        }
1563    }
1564}
1565
1566impl IntoResponse for WebError {
1567    fn into_response(self) -> Response {
1568        warn!(error = %self.err, status = %self.status, "request failed");
1569        let body = if self.status == StatusCode::INTERNAL_SERVER_ERROR {
1570            "internal error"
1571        } else {
1572            self.status.canonical_reason().unwrap_or("error")
1573        };
1574        (self.status, body).into_response()
1575    }
1576}
1577
1578/// Map an axum [`MultipartError`] to a [`WebError`] that preserves the error's
1579/// own HTTP status. When a request exceeds the route's `DefaultBodyLimit` the
1580/// multipart extractor reports `413 Payload Too Large`; a malformed body reports
1581/// `400`. Either way this avoids collapsing the failure into a generic `500`.
1582fn multipart_response(err: axum::extract::multipart::MultipartError) -> WebError {
1583    let status = err.status();
1584    WebError::with_status(err, status)
1585}
1586
1587/// A short, human display of a feed/site title for the sidebar/list, falling
1588/// back to the host of a URL and finally to the raw string.
1589fn display_title(title: Option<&str>, url: &str) -> String {
1590    if let Some(t) = title {
1591        let t = t.trim();
1592        if !t.is_empty() {
1593            return t.to_string();
1594        }
1595    }
1596    url::Url::parse(url)
1597        .ok()
1598        .and_then(|u| u.host_str().map(str::to_string))
1599        .unwrap_or_else(|| url.to_string())
1600}
1601
1602/// A display `@handle` for the identity chip: the stored handle if present,
1603/// else the tail of the DID so the chip is never empty.
1604fn display_handle(handle: Option<&str>, did: &str) -> String {
1605    match handle {
1606        Some(h) if !h.trim().is_empty() => format!("@{}", h.trim().trim_start_matches('@')),
1607        _ => did.rsplit(':').next().unwrap_or(did).to_string(),
1608    }
1609}
1610
1611/// Two-letter, lowercase avatar initials from a handle/DID.
1612fn avatar_initials(handle: Option<&str>, did: &str) -> String {
1613    let source = handle
1614        .map(|h| h.trim().trim_start_matches('@'))
1615        .filter(|h| !h.is_empty())
1616        .unwrap_or_else(|| did.rsplit(':').next().unwrap_or(did));
1617    let letters: String = source
1618        .chars()
1619        .filter(|c| c.is_alphanumeric())
1620        .take(2)
1621        .collect::<String>()
1622        .to_lowercase();
1623    if letters.is_empty() {
1624        "fr".to_string()
1625    } else {
1626        letters
1627    }
1628}
1629
1630/// Trim a stored RFC3339 timestamp down to the `YYYY-MM-DD` date for calm,
1631/// low-noise display. Falls back to the raw string if it doesn't look like one.
1632fn display_date(published: Option<&str>) -> String {
1633    // CHARACTERS, not bytes. `p[..10]` panics when byte 10 lands inside a
1634    // multi-byte character, and every caller used to pass a timestamp the feed
1635    // parser had produced. The saved-record path passes `createdAt` straight off
1636    // a PDS record, which the lexicon types as a bare string with no validation
1637    // — written by whatever atproto client the reader used. A `createdAt` of
1638    // "日本語日本語日本" took down the whole starred view, and there is no
1639    // catch-panic layer in the stack, so the page stayed down until the record
1640    // was removed from the very view that would not render.
1641    match published {
1642        Some(p) => p.chars().take(10).collect(),
1643        None => String::new(),
1644    }
1645}
1646
1647/// Percent-encode a value for use in a query string (RFC 3986 unreserved kept).
1648/// Small and dependency-free — the `url` crate's form-encoding isn't exposed for
1649/// a bare value, and this keeps the scope-preserving links honest.
1650fn qenc(s: &str) -> String {
1651    let mut out = String::with_capacity(s.len() * 3);
1652    for b in s.bytes() {
1653        match b {
1654            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
1655                out.push(b as char)
1656            }
1657            _ => out.push_str(&format!("%{b:02X}")),
1658        }
1659    }
1660    out
1661}
1662
1663// ---------------------------------------------------------------------------
1664// Reader: index
1665// ---------------------------------------------------------------------------
1666
1667/// Query for `GET /` — the scope + view selector.
1668#[derive(Debug, Deserialize, Default)]
1669struct IndexQuery {
1670    /// Filter to a single feed by its canonical URL.
1671    #[serde(default)]
1672    feed: Option<String>,
1673    /// Filter to a folder by its `at://` URI (shows every feed in the folder).
1674    #[serde(default)]
1675    folder: Option<String>,
1676    /// `unread` (default) | `all` | `starred`.
1677    #[serde(default)]
1678    view: Option<String>,
1679    /// 1-based page within the selected scope + view. Absent/0 means page 1.
1680    #[serde(default)]
1681    page: Option<u32>,
1682    /// Optional flash message (e.g. after an action redirect).
1683    #[serde(default)]
1684    flash: Option<String>,
1685}
1686
1687/// Rows per page in the reader's list views.
1688///
1689/// The list projection no longer carries article bodies ([`store::EntryListRow`]),
1690/// so a page is on the order of tens of kilobytes rather than the tens or
1691/// hundreds of megabytes an unbounded list of full entries could reach. The page
1692/// bound is the second half of that fix: without it, a reader with a long
1693/// backlog still decides how much memory a single request allocates.
1694const ENTRIES_PER_PAGE: i64 = 100;
1695
1696/// How many pages `total` entries occupy. An empty list is page 1 of 1, so the
1697/// pager reads "1 / 1" rather than "1 / 0".
1698fn page_count_for(total: i64) -> i64 {
1699    ((total + ENTRIES_PER_PAGE - 1) / ENTRIES_PER_PAGE).max(1)
1700}
1701
1702/// Ceiling on the reader's prev/next id list.
1703///
1704/// Unlike the page above, this genuinely spans the whole list — prev/next is the
1705/// reader's position within it — so it is bounded by count rather than paged. At
1706/// 8 bytes per id this is ~40 KB at the cap. Past it the neighbour links stop
1707/// resolving; the article itself still opens, and the list view still pages.
1708const PREV_NEXT_MAX: i64 = 5_000;
1709
1710/// Ceiling on the cached-starred identity set matched against PDS saved records.
1711///
1712/// Deliberately generous: under-reading this set makes a cached article look
1713/// uncached, and an uncached starred row's button deletes the PDS RECORD rather
1714/// than un-starring the entry. Truncating here would change what a click
1715/// destroys, so the cap exists only as a backstop against an absurd starred
1716/// count, not as a routine bound.
1717const STARRED_IDENTITY_MAX: i64 = 20_000;
1718
1719/// Most uncached PDS saved records this handler will hold in memory for one
1720/// request.
1721///
1722/// **A memory bound, not a visibility bound.** These rows are PAGED alongside
1723/// the cached entries, so `ENTRIES_PER_PAGE` decides how many are rendered and
1724/// this only caps how many are collected before slicing. An earlier version used
1725/// it to cap what was SHOWN, which left everything past it invisible and —
1726/// because the un-save control lives on the row, and nothing else in the app
1727/// lists these — unremovable.
1728///
1729/// Well above the PDS list ceiling's practical reach for one reader, so a reader
1730/// meeting it has thousands of saved records and gets a logged, ordered prefix
1731/// rather than a failure.
1732const MAX_UNCACHED_SAVED_ROWS: usize = 5_000;
1733
1734/// A subscription resolved against the local cache: the PDS record + its
1735/// (possibly-missing) cached feed row.
1736struct ResolvedSub {
1737    rkey: String,
1738    sub: Subscription,
1739    feed: Option<store::Feed>,
1740}
1741
1742/// Pull the user's subscriptions (source of truth = PDS), ensure each has a
1743/// local cache row so unread counts work, and return them resolved. Best-effort
1744/// on the sidecar: a failure falls back to the local cache alone.
1745async fn resolve_subscriptions(state: &AppState, did: &str) -> Vec<ResolvedSub> {
1746    resolve_subscriptions_noting(state, did).await.0
1747}
1748
1749/// What to tell a reader whose subscription list could not be read from their
1750/// PDS, so the last-known list being shown does not pass for a fresh one.
1751///
1752/// **A malformed record is named as such** (#177): the walk refuses rather than
1753/// drop that subscription, and "unreachable" would send the reader looking at
1754/// their network when the cause is a record some client wrote into their repo.
1755fn subscriptions_alert(err: &anyhow::Error) -> String {
1756    match err.downcast_ref::<crate::atproto::MalformedRecords>() {
1757        Some(m) => format!(
1758            "{} record(s) in your subscription list could not be read, so it was not \
1759             refreshed. Showing your last-known subscriptions; nothing was removed.",
1760            m.count
1761        ),
1762        None => "Your subscription list could not be read from your PDS just now. \
1763                 Showing your last-known subscriptions."
1764            .to_string(),
1765    }
1766}
1767
1768/// [`resolve_subscriptions`], plus the alert to show when the list shown is the
1769/// cached one because the PDS listing failed.
1770async fn resolve_subscriptions_noting(
1771    state: &AppState,
1772    did: &str,
1773) -> (Vec<ResolvedSub>, Option<String>) {
1774    let pool = &state.db;
1775    let subs = match state.repo().list_subscriptions_sorted(did).await {
1776        Ok(s) => s,
1777        Err(err) => {
1778            let alert = subscriptions_alert(&err);
1779            warn!(%err, %did, "could not list PDS subscriptions; showing this DID's cached subscriptions only");
1780            // Fail CLOSED: the PDS is the source of truth for what this DID
1781            // follows. When it is unreachable we must NOT widen the caller's
1782            // authorization surface. Serve from the DID's OWN last-known
1783            // `sub_ref` projection (its own feeds, possibly stale) and leave
1784            // `sub_ref` untouched — never synthesize from every cached feed,
1785            // which would grant cross-tenant read+mutate during any outage.
1786            // A DB failure here is NOT the same as "this DID follows nothing",
1787            // but `unwrap_or_default` rendered it as exactly that: an empty
1788            // sidebar and an empty reader, which arrives as "all my feeds
1789            // vanished". It still degrades to empty — there is nothing better to
1790            // show — but it says so, so the support ticket and the log line can
1791            // be matched up.
1792            let feeds = store::feeds_for_did(pool, did).await.unwrap_or_else(|err| {
1793                warn!(%err, %did, "the PDS is unreachable AND the local subscription \
1794                                   projection could not be read; rendering an EMPTY \
1795                                   feed list, which is not the same as having none");
1796                Vec::new()
1797            });
1798            let cached = feeds
1799                .into_iter()
1800                .map(|f| ResolvedSub {
1801                    rkey: String::new(),
1802                    sub: Subscription::new(f.url.clone(), now_rfc3339()),
1803                    feed: Some(f),
1804                })
1805                .collect();
1806            return (cached, Some(alert));
1807        }
1808    };
1809
1810    // **Deliberately NOT truncated to `max_subs_per_did`.**
1811    //
1812    // The PDS list is unbounded in practice — any client can write subscription
1813    // records, and only the 20,000-record list ceiling stops it — and the first
1814    // attempt at bounding it truncated the list right here. That was the wrong
1815    // place: `sync_sub_refs` below writes `sub_ref` from exactly this set, and
1816    // `sub_ref` is THE per-DID authorization hook, so dropping entries silently
1817    // removed the reader's ability to read OR mutate those feeds. A query-shape
1818    // problem would have become an access problem.
1819    //
1820    // The shape problem was the scope filter emitting one SQL placeholder per
1821    // feed; `store::list_query_sql` now passes the whole set as a single
1822    // `json_each` bind, so there is no size to defend against here and nothing
1823    // to truncate. `max_subs_per_did` stays what it is — a policy cap on ADDING
1824    // feeds — rather than becoming a silent read-time filter.
1825    let mut out = Vec::with_capacity(subs.len());
1826    for (rkey, sub) in subs {
1827        let feed = match store::get_feed_by_url(pool, &sub.url).await {
1828            Ok(Some(f)) => Some(f),
1829            Ok(None) => {
1830                // `sub.url` came out of an atproto record. The lexicon is open —
1831                // ANY client can write a subscription into a user's repo — so
1832                // this is untrusted input on the hot path of `GET /`, and it was
1833                // being stored with none of the three checks the add and import
1834                // paths apply. Two of those are capacity ceilings; this one is
1835                // the invariant in `FeedPrivacy`'s doc comment, which promises a
1836                // private feed URL is "never stored". Writing a
1837                // `…/feed/private/<token>` into the SHARED `feeds` table breaks
1838                // that promise even though `net::guarded_get` still refuses to
1839                // fetch it.
1840                if !feed::is_storable_feed_url(&sub.url, state.config.standard_site)
1841                    || feed::classify_feed_privacy(&sub.url).is_private()
1842                {
1843                    warn!(
1844                        %did,
1845                        "skipping cache row for a subscription URL that is private or not http(s)"
1846                    );
1847                    out.push(ResolvedSub {
1848                        rkey,
1849                        sub,
1850                        feed: None,
1851                    });
1852                    continue;
1853                }
1854                // Upsert a cache row so the sidebar reflects the real follow-list.
1855                //
1856                // A silent failure here is a support ticket with no evidence: no
1857                // `feeds` row means the poller never selects this subscription,
1858                // so the reader sees "I added a feed and it never updates" while
1859                // the PDS record looks perfect. Logged with the URL so the
1860                // failing subscription is identifiable.
1861                if let Err(err) = store::upsert_feed(
1862                    pool,
1863                    &store::NewFeed {
1864                        url: sub.url.clone(),
1865                        title: sub.title.clone(),
1866                        site_url: sub.site_url.clone(),
1867                        ..Default::default()
1868                    },
1869                )
1870                .await
1871                {
1872                    warn!(%err, url = %sub.url, %did, "could not cache a subscribed feed; \
1873                                                       it will not be polled");
1874                }
1875                store::get_feed_by_url(pool, &sub.url).await.ok().flatten()
1876            }
1877            Err(err) => {
1878                warn!(%err, url = %sub.url, "get_feed_by_url failed");
1879                None
1880            }
1881        };
1882        out.push(ResolvedSub { rkey, sub, feed });
1883    }
1884    // Mirror the caller's resolved subscription set into `sub_ref`, so every
1885    // scoped entry/feed read + read/star mutation authorizes against exactly
1886    // the feeds this DID follows right now. This is THE per-DID isolation hook.
1887    sync_sub_refs(pool, did, &out).await;
1888    (out, None)
1889}
1890
1891/// Refresh the `sub_ref` projection for `did` to exactly the feed ids present
1892/// in `subs`. Best-effort: a failure here only degrades the scoped reads (they
1893/// fail closed / show fewer rows), never leaks another user's entries.
1894async fn sync_sub_refs(pool: &store::Pool, did: &str, subs: &[ResolvedSub]) {
1895    let feed_ids: Vec<i64> = subs
1896        .iter()
1897        .filter_map(|s| s.feed.as_ref().map(|f| f.id))
1898        .collect();
1899    if let Err(err) = store::replace_sub_refs(pool, did, &feed_ids).await {
1900        warn!(%err, %did, "failed to sync sub_ref projection");
1901    }
1902}
1903
1904/// `GET /` — the reader. Renders the sidebar (folders + feeds from the PDS
1905/// records layer) and the article list for the selected scope + view.
1906async fn index(
1907    State(state): State<AppState>,
1908    headers: HeaderMap,
1909    Query(q): Query<IndexQuery>,
1910) -> Result<Response, WebError> {
1911    let user = match current_session(&state, &headers).await {
1912        Some(u) => u,
1913        // Signed out: serve the public landing page rather than bouncing to
1914        // /login. /login remains the entry point for the actual OAuth sign-in.
1915        None => {
1916            return Ok(render(&LandingTemplate {
1917                version: VERSION,
1918                repo_url: REPO_URL,
1919                crates_url: CRATES_URL,
1920                kofi_url: KOFI_URL,
1921                standard_site: state.config.standard_site,
1922            }))
1923        }
1924    };
1925    let did = user.did.clone();
1926    let pool = &state.db;
1927
1928    let (subs, alert) = resolve_subscriptions_noting(&state, &did).await;
1929
1930    // View: unread (default) | all | starred.
1931    let view = match q.view.as_deref() {
1932        Some("all") => "all",
1933        Some("starred") => "starred",
1934        _ => "unread",
1935    }
1936    .to_string();
1937    let list_view = list_view_of(q.view.as_deref());
1938
1939    // Which feed URLs are in scope?
1940    let scope_urls = scope_urls_for(&subs, q.feed.as_deref(), q.folder.as_deref());
1941    // …and the feed ids they resolve to. Scope is applied inside the query now,
1942    // so a page is a page of rows the reader will actually see. Filtering after
1943    // a `LIMIT` would have made pages arbitrarily short — sometimes empty — for
1944    // any scope narrower than the whole subscription list.
1945    let scope_ids = scoped_feed_ids(&subs, &scope_urls);
1946
1947    let feed_title_by_id = |id: i64| -> String {
1948        subs.iter()
1949            .find(|s| s.feed.as_ref().map(|f| f.id) == Some(id))
1950            .map(|s| {
1951                display_title(
1952                    s.sub
1953                        .title
1954                        .as_deref()
1955                        .or(s.feed.as_ref().and_then(|f| f.title.as_deref())),
1956                    &s.sub.url,
1957                )
1958            })
1959            .unwrap_or_default()
1960    };
1961
1962    // **One page of the chosen view, filtered, ordered and bounded in SQL.**
1963    //
1964    // All three views used to materialize every matching entry — `SELECT e.*`,
1965    // no `LIMIT`, article bodies included — and the "all" view additionally ran
1966    // one such query PER SUBSCRIBED FEED and merged the results in memory. None
1967    // of the row fields below read the body. See `store::EntryListRow`.
1968    // **Saved records the cache cannot show.**
1969    //
1970    // The starred view is built from local `entries`, so a saved record whose
1971    // article was never cached here is invisible — the case that matters is
1972    // starring in ANOTHER atproto reader, which is the portability the shared
1973    // lexicon exists for. Those rows are rendered from the PDS record alone.
1974    let mut uncached: Vec<EntryRow> = Vec::new();
1975    if view == "starred" {
1976        // **Match against every SUBSCRIBED cached starred entry, not `source`.**
1977        //
1978        // `source` has already been filtered by feed/folder. Matching against it
1979        // meant an entry that IS cached but sits outside the current filter
1980        // looked uncached — so it rendered as a "not cached" row whose star
1981        // button deletes the PDS RECORD instead of un-starring the entry. A
1982        // scope filter must not change what is destroyed. Paging is the same
1983        // hazard in a new form: matching against the visible PAGE would make
1984        // every cached article outside it look uncached. Hence a dedicated
1985        // identity query over the whole starred set — urls and guids only, no
1986        // bodies — rather than reusing `source`.
1987        //
1988        // One gap remains BY DESIGN, and is handled at the other end. This query
1989        // still carries the `sub_ref` predicate, so a starred, cached entry in a
1990        // feed the reader has UNSUBSCRIBED from is absent here and its record
1991        // renders as uncached. That is the right rendering — the article is no
1992        // longer part of any feed the reader follows, and the PDS record is what
1993        // still holds it — but it means the un-save button is the record-deleting
1994        // one. `unsave_record` therefore clears the local star too, so the two
1995        // stores agree however the row got classified. Dropping the predicate
1996        // here instead would have made the row link to `/entries/{id}`, which is
1997        // `sub_ref`-scoped and would 404.
1998        //
1999        // **Three ways this can be unusable, and all three fail CLOSED.** With an
2000        // incomplete identity set, a cached article looks uncached and renders an
2001        // un-save button that deletes the PDS RECORD. Showing no uncached rows
2002        // loses rows for one render; getting this wrong loses data permanently,
2003        // so every uncertain case suppresses them.
2004        let identities = match store::starred_identities(pool, &did, STARRED_IDENTITY_MAX).await {
2005            Ok(store::StarredIdentities::All(rows)) => Some(rows),
2006            // The cap is a memory backstop, and reaching it means the set is an
2007            // arbitrary subset. It used to return that subset with no way to
2008            // tell, so every starred article outside it got the destructive
2009            // button.
2010            Ok(store::StarredIdentities::Truncated) => {
2011                warn!(
2012                    %did,
2013                    cap = STARRED_IDENTITY_MAX,
2014                    "cached-starred set exceeded its cap; suppressing uncached saved rows \
2015                     rather than rendering record-deleting buttons for cached articles"
2016                );
2017                None
2018            }
2019            Err(err) => {
2020                warn!(%err, %did, "cached-starred identity lookup failed; \
2021                                    suppressing uncached saved rows this render");
2022                None
2023            }
2024        };
2025        // The escape hatch asks whether this DID has ANY cached starred entry —
2026        // not whether the current SCOPE does. `total` is narrowed by
2027        // `?feed=`/`?folder=` while the identity set spans every feed, so
2028        // comparing them waved the fail-closed condition through for any narrow
2029        // scope: a record whose `feedUrl` matched the filter while its cached
2030        // entry lived under another feed rendered as uncached.
2031        let identities_ok = identities.is_some();
2032        let identities = identities.unwrap_or_default();
2033        let cached_urls: std::collections::HashSet<&str> = identities
2034            .iter()
2035            .filter_map(|(url, _)| url.as_deref())
2036            .collect();
2037        let cached_guids: std::collections::HashSet<&str> =
2038            identities.iter().map(|(_, guid)| guid.as_str()).collect();
2039
2040        // Collected in full here, sliced per page later. They sort after every
2041        // cached row, so the two lists form one sequence that the pager walks —
2042        // see the slice below. Collected BEFORE the page is chosen because the
2043        // page count depends on how many there are.
2044        // Bounded like everything else on this page. These come from the PDS
2045        // (up to the list ceiling — 20,000 on the sidecar backend, 5,000 on
2046        // `backend=rust`, whose caps are a quarter of the other's) and are
2047        // appended whole to the last page, so `ENTRIES_PER_PAGE` does not
2048        // constrain them at all. The
2049        // cap is generous — a reader with more saved-elsewhere records than this
2050        // is not the case being designed for — but a response has to have a size
2051        // an operator can reason about.
2052        let mut uncached_dropped = 0usize;
2053        match state.repo().list_saved_sorted(&did).await {
2054            Ok(saved) if identities_ok => {
2055                for (rkey, item) in saved {
2056                    let known = cached_urls.contains(item.url.as_str())
2057                        || item
2058                            .entry_id
2059                            .as_deref()
2060                            .is_some_and(|g| cached_guids.contains(g));
2061                    if known {
2062                        continue;
2063                    }
2064                    // And the scope filter applies to these rows too. Without
2065                    // it, `?feed=X` still listed saved records from every other
2066                    // feed — the filter silently did nothing for them.
2067                    if let Some(urls) = &scope_urls {
2068                        match item.feed_url.as_deref() {
2069                            Some(feed_url) if urls.iter().any(|u| u == feed_url) => {}
2070                            // A saved record with no `feedUrl` cannot be placed
2071                            // in any feed's scope, so it belongs only to the
2072                            // unfiltered view.
2073                            _ => continue,
2074                        }
2075                    }
2076                    // **`safe_link` FIRST, and a failure no longer drops the row.**
2077                    //
2078                    // `item.url` is attacker-controlled — a saved record written
2079                    // by any client — and it lands in an `href`. Askama escapes
2080                    // HTML metacharacters but not SCHEMES, so `javascript:`
2081                    // survives escaping intact. This project already built the
2082                    // helper for exactly that, and `feed.rs` uses it on the
2083                    // equivalent link; this path was simply not routed through it.
2084                    //
2085                    // The real defect was what a failure DID: it `continue`d, so
2086                    // the row vanished entirely — no badge, no count, nothing —
2087                    // and the only trace was a `debug!` below any realistic
2088                    // filter. That makes the record unremovable FROM HERE, because
2089                    // the un-save button lives on the row; the reader has to open
2090                    // a different atproto client to get rid of it. A bad URL is a
2091                    // reason to withhold the LINK, not the row.
2092                    //
2093                    // The check also moved ABOVE the poll nudge. That is ordering
2094                    // hygiene rather than a fix: the nudge keys on `feed_url`, not
2095                    // on the URL being rejected here, and is already gated on the
2096                    // reader actually subscribing to that feed — so it was never
2097                    // reachable by an unusable `item.url`. Deciding whether a
2098                    // record is renderable before doing anything outbound on its
2099                    // behalf is simply the order that stays correct if either of
2100                    // those two facts later stops being true.
2101                    let link = SafeLink::external(&item.url);
2102                    if link.is_empty() {
2103                        warn!(
2104                            %did, %rkey,
2105                            "a saved record has an unusable URL; rendering it without a link \
2106                             so it can still be removed"
2107                        );
2108                    }
2109
2110                    // Opportunistic re-fetch: if the reader still subscribes to
2111                    // the feed, make it due now. If the article is still inside
2112                    // the feed's window the poller caches it normally and this
2113                    // row becomes a real entry on its own — no synthetic rows in
2114                    // the shared cache, which every subscriber would otherwise
2115                    // see as a content-less entry.
2116                    // **Bound the WORK, not just the response.** This check sat
2117                    // after the nudge and the `subs` scan below, so every render
2118                    // still walked all ≤20,000 PDS records, ran a subs-length
2119                    // string scan per record, and issued up to that many
2120                    // `mark_feed_due` round-trips on a 5-connection pool — then
2121                    // discarded everything past the cap. A cap that runs after
2122                    // the expensive part is a cap on the output only.
2123                    if uncached.len() >= MAX_UNCACHED_SAVED_ROWS {
2124                        uncached_dropped += 1;
2125                        continue;
2126                    }
2127                    if let Some(feed_url) = item.feed_url.as_deref() {
2128                        if subs.iter().any(|s| s.sub.url == feed_url) {
2129                            // Bounded to one nudge per feed per poll interval —
2130                            // see `mark_feed_due`. Unbounded, a reload loop here
2131                            // becomes outbound amplification.
2132                            let stale_before = (chrono::Utc::now()
2133                                - chrono::Duration::from_std(state.config.poll_interval)
2134                                    .unwrap_or_else(|_| chrono::Duration::hours(1)))
2135                            .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
2136                            if let Err(err) =
2137                                store::mark_feed_due(pool, feed_url, &stale_before).await
2138                            {
2139                                tracing::debug!(%err, %feed_url, "could not nudge a feed for a saved article");
2140                            }
2141                        }
2142                    }
2143                    uncached.push(EntryRow {
2144                        id: 0,
2145                        title: item
2146                            .title
2147                            .clone()
2148                            .filter(|t| !t.trim().is_empty())
2149                            // Falling back to the URL is fine for a link we are
2150                            // willing to render, and wrong for one we are not:
2151                            // it would put the exact string `safe_link` just
2152                            // rejected into the page as the record's name. The
2153                            // rkey is what the un-save button acts on, so it is
2154                            // the honest identifier for a row that has nothing
2155                            // else trustworthy to show.
2156                            .unwrap_or_else(|| {
2157                                if link.is_empty() {
2158                                    format!("Saved item {rkey}")
2159                                } else {
2160                                    item.url.clone()
2161                                }
2162                            }),
2163                        feed_title: item.feed_url.clone().unwrap_or_default(),
2164                        published: display_date(Some(&item.created_at)),
2165                        read: false,
2166                        starred: true,
2167                        // Empty = "render this row without an anchor". The
2168                        // template branches on it, so the rejected URL never
2169                        // reaches an `href` even as an escaped string.
2170                        link,
2171                        cached: false,
2172                        rkey,
2173                    });
2174                }
2175            }
2176            // Identity lookup was unusable — see the fail-closed note above.
2177            Ok(_) => {}
2178            Err(err) => warn!(%err, %did, "could not list saved records from the PDS"),
2179        }
2180        if uncached_dropped > 0 {
2181            warn!(
2182                %did,
2183                dropped = uncached_dropped,
2184                cap = MAX_UNCACHED_SAVED_ROWS,
2185                "more saved records than this instance will hold in one response; the \
2186                 rest are not reachable from here"
2187            );
2188        }
2189    }
2190
2191    // **One sequence, two sources.** The cached rows come from SQL, the uncached
2192    // PDS records follow them, and the pager walks the concatenation.
2193    //
2194    // The first version appended the uncached rows to the last page only and
2195    // kept them out of `total`, which left everything past a cap invisible AND
2196    // unremovable — the un-save button lives on the row, and there is no other
2197    // surface in the app that lists these. That is the same "unremovable FROM
2198    // HERE" hazard the `safe_link` fix above exists to prevent, reintroduced
2199    // forty lines later by a bound meant to protect memory.
2200    //
2201    // Paging the concatenation makes every record reachable and needs no cap on
2202    // what is RENDERED — one page is one page either way. The version before
2203    // that inflated `total` while clamping on the cached count, which advertised
2204    // a page the clamp could never reach; both numbers come from the same total
2205    // now, which is what makes that impossible rather than merely fixed.
2206    let total_cached =
2207        store::count_entries_for_view(pool, &did, list_view, scope_ids.as_deref()).await?;
2208    let uncached_len = uncached.len();
2209    let total = total_cached + uncached_len as i64;
2210    // Clamped to the range that exists. Past the end the list is empty, and the
2211    // empty state renders instead of the pager — which would strand a reader who
2212    // typed a page number, or who paged to the end and then marked entries read
2213    // out from under their own URL. Showing the last page is the answer to both.
2214    let page = i64::from(q.page.unwrap_or(1).max(1)).min(page_count_for(total));
2215    let offset = (page - 1) * ENTRIES_PER_PAGE;
2216    // Past the cached rows this returns nothing, which is exactly right: the
2217    // page is then made up entirely of uncached ones.
2218    let source = store::list_entries(
2219        pool,
2220        &did,
2221        list_view,
2222        scope_ids.as_deref(),
2223        ENTRIES_PER_PAGE,
2224        offset,
2225    )
2226    .await?;
2227    // **Both halves of the page are computed from the COUNT alone.**
2228    //
2229    // `total_cached` (a COUNT) and `source` (a SELECT) are separate unsynchronised
2230    // queries, so they can disagree about how many cached rows exist. Any part of
2231    // the page composition that reads `source.len()` inherits that disagreement.
2232    //
2233    // `cached_allotment` is this page's cached share according to the snapshot,
2234    // and it is what the uncached `skip`/`take` are derived from — so consecutive
2235    // pages tile the uncached list exactly, whichever way the count drifted.
2236    // `source` is then truncated to it only to avoid rendering rows the next page
2237    // will also claim.
2238    //
2239    // The previous version took `skip` from the count but `take` from
2240    // `source.len()`, which agreed only when the count UNDERSTATED. Overstating —
2241    // an un-star or a retention delete landing between the two queries — made
2242    // page N render `uncached[0..70]` while page N+1 rendered `uncached[50..80]`,
2243    // putting twenty rows, each carrying the record-DELETING un-save button, on
2244    // two pages at once. The comment claimed that shape was impossible; it was
2245    // merely rarer.
2246    let cached_allotment = (total_cached - offset).clamp(0, ENTRIES_PER_PAGE) as usize;
2247    let cached_here = cached_allotment.min(source.len());
2248    // Only compose when there is something to compose WITH. `uncached` is empty
2249    // on every view but `starred`, and truncating there just drops trailing rows
2250    // that no page then shows — the poller inserting between the COUNT and the
2251    // SELECT was enough to trigger it.
2252    let source = if uncached_len == 0 {
2253        &source[..]
2254    } else {
2255        &source[..cached_here]
2256    };
2257    let uncached_page: Vec<EntryRow> = {
2258        let skip = (offset - total_cached).max(0) as usize;
2259        let take = (ENTRIES_PER_PAGE as usize) - cached_allotment;
2260        uncached.into_iter().skip(skip).take(take).collect()
2261    };
2262    // This page's slice, used only to append below. The heading needs the
2263    // WHOLE-list figure, which is the set's size before slicing.
2264    let uncached_total = uncached_len as i64;
2265
2266    // The scope/view suffix carried onto every entry link (built once).
2267    let entry_scope_qs = {
2268        let mut parts = Vec::new();
2269        if let Some(f) = q.feed.as_deref() {
2270            parts.push(format!("feed={}", qenc(f)));
2271        }
2272        if let Some(f) = q.folder.as_deref() {
2273            parts.push(format!("folder={}", qenc(f)));
2274        }
2275        if view != "unread" {
2276            parts.push(format!("view={}", qenc(&view)));
2277        }
2278        parts.join("&")
2279    };
2280    let entries: Vec<EntryRow> = source
2281        .iter()
2282        .map(|e| EntryRow {
2283            id: e.id,
2284            title: e
2285                .title
2286                .clone()
2287                .filter(|t| !t.trim().is_empty())
2288                .unwrap_or_else(|| "(untitled)".to_string()),
2289            feed_title: feed_title_by_id(e.feed_id),
2290            published: display_date(e.published.as_deref()),
2291            // Both bits ride along on the row's own `entry_state` join now. They
2292            // used to be membership tests against the full unread and starred
2293            // sets, which is why those two lists were fetched in their entirety
2294            // on every render even when the page showed a hundred rows.
2295            read: e.read,
2296            starred: e.starred,
2297            link: SafeLink::entry(e.id, &entry_scope_qs),
2298            cached: true,
2299            rkey: String::new(),
2300        })
2301        .collect();
2302
2303    // The uncached slice for this page follows the cached rows.
2304    let mut entries = entries;
2305    entries.extend(uncached_page);
2306    let entries = entries;
2307
2308    let selected_feed = q.feed.as_deref();
2309    let selected_folder = q.folder.as_deref();
2310
2311    // Build the shared sidebar (folders + loose feeds, with unread counts).
2312    let (folder_views, loose_feeds, _folder_options) =
2313        build_sidebar(&state, &did, &subs, selected_feed, selected_folder).await;
2314
2315    // Heading + scope query-string suffix.
2316    let (heading, scope_qs) = if let Some(feed_url) = selected_feed {
2317        let name = subs
2318            .iter()
2319            .find(|s| s.sub.url == feed_url)
2320            .map(|s| {
2321                display_title(
2322                    s.sub
2323                        .title
2324                        .as_deref()
2325                        .or(s.feed.as_ref().and_then(|f| f.title.as_deref())),
2326                    &s.sub.url,
2327                )
2328            })
2329            .unwrap_or_else(|| display_title(None, feed_url));
2330        (name, format!("feed={}", qenc(feed_url)))
2331    } else if let Some(folder_uri) = selected_folder {
2332        let name = folder_views
2333            .iter()
2334            .find(|f| f.uri == folder_uri)
2335            .map(|f| f.name.clone())
2336            .unwrap_or_else(|| "Folder".to_string());
2337        (name, format!("folder={}", qenc(folder_uri)))
2338    } else {
2339        let h = match view.as_str() {
2340            "all" => "All",
2341            "starred" => "Starred",
2342            _ => "Unread",
2343        };
2344        (h.to_string(), String::new())
2345    };
2346
2347    let feed_scope = selected_feed.map(str::to_string);
2348    let nav = build_nav(&user, &view, scope_qs, folder_views, loose_feeds, false);
2349
2350    // Pager links. `entry_scope_qs` already carries feed/folder/view, so the
2351    // page number is the only thing appended — which keeps a paged link
2352    // identical to an unpaged one in every other respect.
2353    let page_href = |n: i64| -> String {
2354        let mut parts = Vec::new();
2355        if !entry_scope_qs.is_empty() {
2356            parts.push(entry_scope_qs.clone());
2357        }
2358        if n > 1 {
2359            parts.push(format!("page={n}"));
2360        }
2361        if parts.is_empty() {
2362            "/".to_string()
2363        } else {
2364            format!("/?{}", parts.join("&"))
2365        }
2366    };
2367    let prev_href = (page > 1).then(|| page_href(page - 1));
2368    let next_href = (page * ENTRIES_PER_PAGE < total).then(|| page_href(page + 1));
2369
2370    let tmpl = IndexTemplate {
2371        version: VERSION,
2372        repo_url: REPO_URL,
2373        kofi_url: KOFI_URL,
2374        flash: q.flash.unwrap_or_default(),
2375        alert: alert.unwrap_or_default(),
2376        nav,
2377        entries,
2378        heading,
2379        feed_scope,
2380        total,
2381        // Whole-list figure, so it sits beside `total` without double counting.
2382        // The per-page slice is composed above and is not a heading number.
2383        uncached_total,
2384        page,
2385        page_count: page_count_for(total),
2386        prev_href,
2387        next_href,
2388    };
2389    Ok(render(&tmpl))
2390}
2391
2392/// Query for `GET /manage` — carries an optional flash after an action redirect.
2393#[derive(Debug, Deserialize, Default)]
2394struct ManageQuery {
2395    #[serde(default)]
2396    flash: Option<String>,
2397}
2398
2399/// `GET /manage` — the feed-management page. Renders the rail plus the subscribe
2400/// / your-feeds / OPML surfaces; the forms POST to the existing routes
2401/// (`/subscriptions`, `/folders`, `/opml`, …). A read/render route only — no
2402/// mutation logic of its own.
2403async fn manage(
2404    State(state): State<AppState>,
2405    headers: HeaderMap,
2406    Query(q): Query<ManageQuery>,
2407) -> Result<Response, WebError> {
2408    let user = match current_session(&state, &headers).await {
2409        Some(u) => u,
2410        None => return Ok(Redirect::to("/login").into_response()),
2411    };
2412    let did = user.did.clone();
2413
2414    let (subs, alert) = resolve_subscriptions_noting(&state, &did).await;
2415    let (folder_views, loose_feeds, folder_options) =
2416        build_sidebar(&state, &did, &subs, None, None).await;
2417
2418    // Clone the sidebar for the rail; the page body reuses folders/loose feeds.
2419    let nav = build_nav(
2420        &user,
2421        "unread",
2422        String::new(),
2423        folder_views.iter().map(clone_folder_view).collect(),
2424        loose_feeds.iter().map(clone_feed_view).collect(),
2425        true,
2426    );
2427
2428    let tmpl = ManageTemplate {
2429        version: VERSION,
2430        repo_url: REPO_URL,
2431        kofi_url: KOFI_URL,
2432        flash: q.flash.unwrap_or_default(),
2433        alert: alert.unwrap_or_default(),
2434        nav,
2435        folder_options,
2436        folders: folder_views,
2437        loose_feeds,
2438        standard_site: state.config.standard_site,
2439    };
2440    Ok(render(&tmpl))
2441}
2442
2443/// Shallow clone helpers so `/manage` can hand the same sidebar to both the rail
2444/// (`Nav`) and the page body without an extra DB round-trip.
2445fn clone_feed_view(f: &FeedView) -> FeedView {
2446    FeedView {
2447        rkey: f.rkey.clone(),
2448        url: f.url.clone(),
2449        title: f.title.clone(),
2450        unread: f.unread,
2451        selected: f.selected,
2452        folder: f.folder.clone(),
2453    }
2454}
2455
2456fn clone_folder_view(f: &FolderView) -> FolderView {
2457    FolderView {
2458        rkey: f.rkey.clone(),
2459        uri: f.uri.clone(),
2460        name: f.name.clone(),
2461        feeds: f.feeds.iter().map(clone_feed_view).collect(),
2462        selected: f.selected,
2463    }
2464}
2465
2466/// The set of feed URLs a scope covers: `Some([one url])` for a single-feed
2467/// scope, `Some([urls…])` for a folder (its member feeds), or `None` for the
2468/// unscoped "everything" view. A folder scope takes the feed scope when both are
2469/// somehow present (feed wins, matching the query precedence elsewhere).
2470fn scope_urls_for(
2471    subs: &[ResolvedSub],
2472    feed: Option<&str>,
2473    folder: Option<&str>,
2474) -> Option<Vec<String>> {
2475    if let Some(feed_url) = feed {
2476        Some(vec![feed_url.to_string()])
2477    } else {
2478        folder.map(|folder_uri| {
2479            subs.iter()
2480                .filter(|s| s.sub.folder.as_deref() == Some(folder_uri))
2481                .map(|s| s.sub.url.clone())
2482                .collect()
2483        })
2484    }
2485}
2486
2487/// The `at://` URI for a folder record given the owner DID + rkey.
2488fn folder_uri(did: &str, rkey: &str) -> String {
2489    format!("at://{did}/{}/{rkey}", lexicon::nsid::FOLDER)
2490}
2491
2492/// Build the sidebar folder/loose-feed views (with per-feed unread counts) for a
2493/// DID — the shared source for both the reader index and the rail on every
2494/// chrome page. `selected_feed` / `selected_folder` drive `aria-current`.
2495async fn build_sidebar(
2496    state: &AppState,
2497    did: &str,
2498    subs: &[ResolvedSub],
2499    selected_feed: Option<&str>,
2500    selected_folder: Option<&str>,
2501) -> (Vec<FolderView>, Vec<FeedView>, Vec<FolderOption>) {
2502    let pool = &state.db;
2503    // Counted in SQL. This used to fetch every unread ENTRY — article bodies and
2504    // all — purely to `.filter().count()` them in Rust, on every page that
2505    // renders chrome, which made the sidebar the most frequently executed
2506    // instance of the unbounded-projection problem.
2507    let unread_counts = store::unread_counts_by_feed(pool, did)
2508        .await
2509        .unwrap_or_else(|err| {
2510            warn!(%err, %did, "sidebar unread counts failed; rendering zeroes");
2511            Default::default()
2512        });
2513    let folders = state
2514        .repo()
2515        .list_folders_sorted(did)
2516        .await
2517        .unwrap_or_default();
2518
2519    let unread_count = |feed_id: Option<i64>| -> i64 {
2520        feed_id
2521            .and_then(|id| unread_counts.get(&id).copied())
2522            .unwrap_or(0)
2523    };
2524    let mk_feed_view = |s: &ResolvedSub| FeedView {
2525        rkey: s.rkey.clone(),
2526        url: s.sub.url.clone(),
2527        title: display_title(
2528            s.sub
2529                .title
2530                .as_deref()
2531                .or(s.feed.as_ref().and_then(|f| f.title.as_deref())),
2532            &s.sub.url,
2533        ),
2534        unread: unread_count(s.feed.as_ref().map(|f| f.id)),
2535        selected: selected_feed == Some(s.sub.url.as_str()),
2536        folder: s.sub.folder.clone(),
2537    };
2538
2539    let mut folder_views = Vec::with_capacity(folders.len());
2540    for (rkey, folder) in &folders {
2541        let uri = folder_uri(did, rkey);
2542        let feeds: Vec<FeedView> = subs
2543            .iter()
2544            .filter(|s| s.sub.folder.as_deref() == Some(uri.as_str()))
2545            .map(mk_feed_view)
2546            .collect();
2547        folder_views.push(FolderView {
2548            rkey: rkey.clone(),
2549            uri: uri.clone(),
2550            name: folder.name.clone(),
2551            feeds,
2552            selected: selected_folder == Some(uri.as_str()),
2553        });
2554    }
2555
2556    let known_uris: std::collections::HashSet<String> =
2557        folders.iter().map(|(r, _)| folder_uri(did, r)).collect();
2558    let loose_feeds: Vec<FeedView> = subs
2559        .iter()
2560        .filter(|s| {
2561            s.sub
2562                .folder
2563                .as_deref()
2564                .map(|f| !known_uris.contains(f))
2565                .unwrap_or(true)
2566        })
2567        .map(mk_feed_view)
2568        .collect();
2569
2570    let folder_options: Vec<FolderOption> = folders
2571        .iter()
2572        .map(|(rkey, folder)| FolderOption {
2573            name: folder.name.clone(),
2574            uri: folder_uri(did, rkey),
2575        })
2576        .collect();
2577
2578    (folder_views, loose_feeds, folder_options)
2579}
2580
2581/// Assemble the shared rail [`Nav`] for a chrome page.
2582fn build_nav(
2583    user: &CurrentUser,
2584    view: &str,
2585    scope_qs: String,
2586    folders: Vec<FolderView>,
2587    loose_feeds: Vec<FeedView>,
2588    manage_active: bool,
2589) -> Nav {
2590    Nav {
2591        handle: display_handle(user.handle.as_deref(), &user.did),
2592        avatar: avatar_initials(user.handle.as_deref(), &user.did),
2593        view: view.to_string(),
2594        scope_qs,
2595        folders,
2596        loose_feeds,
2597        manage_active,
2598    }
2599}
2600
2601// ---------------------------------------------------------------------------
2602// Reader: single entry
2603// ---------------------------------------------------------------------------
2604
2605/// Query for `GET /entries/:id` — carries the reading context (scope + view) so
2606/// prev/next and "back" stay within the list the reader came from.
2607#[derive(Debug, Deserialize, Default)]
2608struct EntryQuery {
2609    #[serde(default)]
2610    feed: Option<String>,
2611    #[serde(default)]
2612    folder: Option<String>,
2613    #[serde(default)]
2614    view: Option<String>,
2615}
2616
2617/// `GET /entries/:id` — the clean reader view for one entry, with prev/next
2618/// within the current reading list.
2619async fn entry_view(
2620    State(state): State<AppState>,
2621    headers: HeaderMap,
2622    Path(id): Path<i64>,
2623    Query(q): Query<EntryQuery>,
2624) -> Result<Response, WebError> {
2625    let user = match current_session(&state, &headers).await {
2626        Some(u) => u,
2627        None => return Ok(Redirect::to("/login").into_response()),
2628    };
2629    let did = user.did.clone();
2630    let pool = &state.db;
2631
2632    // Resolve subscriptions FIRST: this refreshes the `sub_ref` projection so
2633    // the per-DID entry gate below authorizes against the caller's current PDS
2634    // subscription set (not another user's cached feeds).
2635    let subs = resolve_subscriptions(&state, &did).await;
2636
2637    let entry = match get_entry_by_id(pool, &did, id).await? {
2638        Some(e) => e,
2639        None => return Ok((StatusCode::NOT_FOUND, "entry not found").into_response()),
2640    };
2641
2642    let feed_title = feed_title_by_entry(pool, entry.feed_id).await;
2643
2644    let read = entry_is_read(pool, &did, id).await?;
2645    let starred = entry_is_starred(pool, &did, id).await?;
2646
2647    // Reconstruct the current list to compute prev/next, so paging in the reader
2648    // matches what the list showed.
2649    let (prev_id, next_id) = neighbors_in_scope(&state, &did, &q, id).await;
2650
2651    let back_qs = scope_query(&q);
2652
2653    let (folder_views, loose_feeds, _) =
2654        build_sidebar(&state, &did, &subs, q.feed.as_deref(), q.folder.as_deref()).await;
2655    let nav_view = match q.view.as_deref() {
2656        Some("all") => "all",
2657        Some("starred") => "starred",
2658        _ => "unread",
2659    };
2660    let nav = build_nav(
2661        &user,
2662        nav_view,
2663        back_qs.clone(),
2664        folder_views,
2665        loose_feeds,
2666        false,
2667    );
2668
2669    let tmpl = EntryTemplate {
2670        version: VERSION,
2671        repo_url: REPO_URL,
2672        kofi_url: KOFI_URL,
2673        nav,
2674        id: entry.id,
2675        title: entry
2676            .title
2677            .clone()
2678            .filter(|t| !t.trim().is_empty())
2679            .unwrap_or_else(|| "(untitled)".to_string()),
2680        feed_title,
2681        author: entry.author.clone().filter(|a| !a.trim().is_empty()),
2682        published: display_date(entry.published.as_deref()),
2683        url: entry.url.as_deref().and_then(SafeLink::external_opt),
2684        content_html: entry.content_html.clone(),
2685        read,
2686        starred,
2687        back_qs,
2688        prev_id,
2689        next_id,
2690        oob: false,
2691    };
2692    Ok(render(&tmpl))
2693}
2694
2695/// Compute the prev/next entry ids around `current` within the reader's current
2696/// scope + view, so the reader view can offer keyboard/paging navigation.
2697async fn neighbors_in_scope(
2698    state: &AppState,
2699    did: &str,
2700    q: &EntryQuery,
2701    current: i64,
2702) -> (Option<i64>, Option<i64>) {
2703    let idx_q = IndexQuery {
2704        feed: q.feed.clone(),
2705        folder: q.folder.clone(),
2706        view: q.view.clone(),
2707        // Neighbours span the whole list, not the page the reader arrived from.
2708        page: None,
2709        flash: None,
2710    };
2711    let ids = list_entry_ids(state, did, &idx_q).await;
2712    let pos = ids.iter().position(|&x| x == current);
2713    match pos {
2714        Some(p) => {
2715            let prev = if p > 0 { Some(ids[p - 1]) } else { None };
2716            let next = ids.get(p + 1).copied();
2717            (prev, next)
2718        }
2719        None => (None, None),
2720    }
2721}
2722
2723/// The ordered entry ids for a scope + view — the same ordering `index` renders,
2724/// used for reader prev/next. Best-effort; PDS failures degrade to local cache.
2725async fn list_entry_ids(state: &AppState, did: &str, q: &IndexQuery) -> Vec<i64> {
2726    let pool = &state.db;
2727    let subs = resolve_subscriptions(state, did).await;
2728
2729    let scope_urls = scope_urls_for(&subs, q.feed.as_deref(), q.folder.as_deref());
2730
2731    // Ids only, and bounded. This used to fetch whole entries — bodies included
2732    // — for all three views and then throw everything but `id` away; the "all"
2733    // branch additionally ran one unbounded query PER FEED and sorted the union
2734    // in memory. Scope is now a feed-id restriction inside the query, so the
2735    // database does the filtering and the ordering exactly once.
2736    store::list_entry_ids(
2737        pool,
2738        did,
2739        list_view_of(q.view.as_deref()),
2740        scoped_feed_ids(&subs, &scope_urls).as_deref(),
2741        PREV_NEXT_MAX,
2742    )
2743    .await
2744    .unwrap_or_else(|err| {
2745        warn!(%err, %did, "prev/next id list failed; the reader loses its neighbour links");
2746        Vec::new()
2747    })
2748}
2749
2750/// Map the `?view=` query value onto the store's list view. Anything
2751/// unrecognised is the unread default, matching `index`.
2752fn list_view_of(view: Option<&str>) -> store::ListView {
2753    match view {
2754        Some("all") => store::ListView::All,
2755        Some("starred") => store::ListView::Starred,
2756        _ => store::ListView::Unread,
2757    }
2758}
2759
2760/// Translate a feed/folder scope into the feed ids to restrict a list query to.
2761///
2762/// `None` means unscoped (every subscribed feed). `Some(&[])` means the scope
2763/// matched no local feed, which must return nothing rather than everything — so
2764/// the empty vec is deliberately preserved, not collapsed back into `None`.
2765fn scoped_feed_ids(subs: &[ResolvedSub], scope_urls: &Option<Vec<String>>) -> Option<Vec<i64>> {
2766    let urls = scope_urls.as_ref()?;
2767    Some(
2768        subs.iter()
2769            .filter(|s| urls.contains(&s.sub.url))
2770            .filter_map(|s| s.feed.as_ref().map(|f| f.id))
2771            .collect(),
2772    )
2773}
2774
2775/// Build a `?…` query string that preserves the reading scope + view for links.
2776fn scope_query(q: &EntryQuery) -> String {
2777    let mut parts = Vec::new();
2778    if let Some(f) = q.feed.as_deref() {
2779        parts.push(format!("feed={}", qenc(f)));
2780    }
2781    if let Some(f) = q.folder.as_deref() {
2782        parts.push(format!("folder={}", qenc(f)));
2783    }
2784    if let Some(v) = q.view.as_deref() {
2785        if v != "unread" {
2786            parts.push(format!("view={}", qenc(v)));
2787        }
2788    }
2789    parts.join("&")
2790}
2791
2792// ---------------------------------------------------------------------------
2793// Mark read / unread
2794// ---------------------------------------------------------------------------
2795
2796/// Form body for `POST /entries/:id/read`.
2797#[derive(Debug, Deserialize)]
2798struct ReadForm {
2799    #[serde(default)]
2800    read: Option<String>,
2801}
2802
2803/// `POST /entries/:id/read` — toggle an entry's read-state for the current DID.
2804async fn mark_read(
2805    State(state): State<AppState>,
2806    Path(id): Path<i64>,
2807    headers: HeaderMap,
2808    Form(form): Form<ReadForm>,
2809) -> Result<Response, WebError> {
2810    let did = match current_did(&state, &headers).await {
2811        Some(d) => d,
2812        None => return Ok(Redirect::to("/login").into_response()),
2813    };
2814    let pool = &state.db;
2815
2816    let read = matches!(
2817        form.read.as_deref(),
2818        Some("true") | Some("1") | Some("on") | None
2819    );
2820
2821    // Refresh the caller's `sub_ref` projection, then apply the AUTHORIZED
2822    // mutation: `mark_read` only writes when `did` subscribes to the entry's
2823    // feed. A non-subscriber gets a 404, never a mutation of someone else's
2824    // (or the shared cache's) state.
2825    resolve_subscriptions(&state, &did).await;
2826    if !store::mark_read(pool, &did, id, read).await? {
2827        return Ok((StatusCode::NOT_FOUND, "entry not found").into_response());
2828    }
2829
2830    if !is_htmx(&headers) {
2831        return Ok(Redirect::to("/").into_response());
2832    }
2833
2834    // The reader view swaps an out-of-band action-bar fragment (its `<li>` isn't
2835    // in the DOM), so its button's hidden value + aria-pressed update in place
2836    // and a second keypress can reverse the toggle. The list view swaps the row.
2837    if is_reader_request(&headers) {
2838        let starred = entry_is_starred(pool, &did, id).await?;
2839        return Ok(render(&EntryActionBarTemplate {
2840            id,
2841            read,
2842            starred,
2843            oob: true,
2844        }));
2845    }
2846
2847    let row = build_entry_row(pool, &did, id, Some(read)).await?;
2848    match row {
2849        Some(r) => Ok(render(&EntryRowTemplate { e: r })),
2850        None => Ok((StatusCode::NOT_FOUND, "entry not found").into_response()),
2851    }
2852}
2853
2854// ---------------------------------------------------------------------------
2855// Star / save
2856// ---------------------------------------------------------------------------
2857
2858/// Form body for `POST /entries/:id/star`.
2859#[derive(Debug, Deserialize)]
2860struct StarForm {
2861    #[serde(default)]
2862    starred: Option<String>,
2863}
2864
2865/// `POST /entries/:id/star` — star/unstar an entry.
2866///
2867/// Sets the local `starred` bit (fast working copy) and writes/removes a
2868/// `community.lexicon.rss.saved` record in the user's PDS (stars are worth
2869/// owning). The PDS write is best-effort — the local star still lands.
2870async fn toggle_star(
2871    State(state): State<AppState>,
2872    Path(id): Path<i64>,
2873    headers: HeaderMap,
2874    Form(form): Form<StarForm>,
2875) -> Result<Response, WebError> {
2876    let did = match current_did(&state, &headers).await {
2877        Some(d) => d,
2878        None => return Ok(Redirect::to("/login").into_response()),
2879    };
2880    let pool = &state.db;
2881
2882    let starred = matches!(
2883        form.starred.as_deref(),
2884        Some("true") | Some("1") | Some("on") | None
2885    );
2886
2887    // Refresh the caller's `sub_ref` projection, then apply the AUTHORIZED
2888    // mutation: `mark_starred` only writes when `did` subscribes to the entry's
2889    // feed. A non-subscriber gets a 404, never a mutation.
2890    resolve_subscriptions(&state, &did).await;
2891    if !store::mark_starred(pool, &did, id, starred).await? {
2892        return Ok((StatusCode::NOT_FOUND, "entry not found").into_response());
2893    }
2894
2895    // Reflect into the PDS saved-records collection. `get_entry_by_id` is scoped
2896    // to the caller's subscriptions, so this only ever acts on the caller's feed.
2897    if let Ok(Some(entry)) = get_entry_by_id(pool, &did, id).await {
2898        let entry_url = entry.url.clone().unwrap_or_default();
2899        if !entry_url.is_empty() {
2900            if starred {
2901                let mut saved = Saved::new(entry_url.clone(), now_rfc3339());
2902                saved.title = entry.title.clone();
2903                saved.feed_url = feed_url_for_id(pool, entry.feed_id).await;
2904                saved.entry_id = Some(entry.guid.clone());
2905                match state.repo().add_saved(&did, &saved).await {
2906                    Ok(rkey) => info!(%did, url = %entry_url, %rkey, "wrote saved record to PDS"),
2907                    Err(err) => warn!(%err, %did, "PDS saved write failed (starred locally)"),
2908                }
2909            } else {
2910                // Un-star: find and delete the matching saved record by URL.
2911                match state.repo().list_saved(&did).await {
2912                    Ok(records) => {
2913                        for (rkey, _rec) in records.iter().filter(|(_, r)| r.url == entry_url) {
2914                            if let Err(err) = state.repo().remove_saved(&did, rkey).await {
2915                                warn!(%err, %did, %rkey, "PDS saved delete failed");
2916                            }
2917                        }
2918                    }
2919                    Err(err) => warn!(%err, %did, "could not list saved records to un-star"),
2920                }
2921            }
2922        }
2923    }
2924
2925    if !is_htmx(&headers) {
2926        return Ok(Redirect::to("/").into_response());
2927    }
2928
2929    // Reader → out-of-band action-bar fragment; list → the row (see mark_read).
2930    if is_reader_request(&headers) {
2931        let read = entry_is_read(pool, &did, id).await?;
2932        return Ok(render(&EntryActionBarTemplate {
2933            id,
2934            read,
2935            starred,
2936            oob: true,
2937        }));
2938    }
2939
2940    let row = build_entry_row(pool, &did, id, None).await?;
2941    match row {
2942        Some(r) => Ok(render(&EntryRowTemplate { e: r })),
2943        None => Ok((StatusCode::NOT_FOUND, "entry not found").into_response()),
2944    }
2945}
2946
2947/// The feed URL for a cached feed id, if the row exists.
2948async fn feed_url_for_id(pool: &store::Pool, feed_id: i64) -> Option<String> {
2949    sqlx::query_scalar::<_, String>("SELECT url FROM feeds WHERE id = ?1")
2950        .bind(feed_id)
2951        .fetch_optional(pool)
2952        .await
2953        .ok()
2954        .flatten()
2955}
2956
2957// ---------------------------------------------------------------------------
2958// Mark-all-read
2959// ---------------------------------------------------------------------------
2960
2961/// Query for `POST /read-all` — an optional `?feed=<url>` scopes it to one feed;
2962/// absent means mark everything read.
2963#[derive(Debug, Deserialize, Default)]
2964struct ReadAllQuery {
2965    #[serde(default)]
2966    feed: Option<String>,
2967}
2968
2969/// `POST /read-all` — mark every entry read for the current DID, optionally
2970/// scoped to one feed (mark-all-read per feed or globally).
2971async fn mark_all_read(
2972    State(state): State<AppState>,
2973    headers: HeaderMap,
2974    Query(q): Query<ReadAllQuery>,
2975) -> Result<Response, WebError> {
2976    let did = match current_did(&state, &headers).await {
2977        Some(d) => d,
2978        None => return Ok(Redirect::to("/login").into_response()),
2979    };
2980    let pool = &state.db;
2981
2982    // Refresh the caller's `sub_ref` projection so the scoped mark-read writes
2983    // only ever touch feeds this DID actually subscribes to.
2984    resolve_subscriptions(&state, &did).await;
2985
2986    if let Some(feed_url) = q.feed.as_deref() {
2987        if let Ok(Some(feed)) = store::get_feed_by_url(pool, feed_url).await {
2988            store::mark_feed_read(pool, &did, feed.id, true).await?;
2989        }
2990        return Ok(Redirect::to(&format!("/?feed={}", qenc(feed_url))).into_response());
2991    }
2992
2993    // Global: mark every subscribed feed read. Fan out over the DID's feeds
2994    // (bounded by the per-DID subscription cap) using the batched per-feed path,
2995    // rather than one UPDATE round-trip per unread entry (unbounded) — same end
2996    // state, but O(feeds) statements instead of O(unread entries).
2997    for feed_id in store::subscribed_feed_ids(pool, &did).await? {
2998        store::mark_feed_read(pool, &did, feed_id, true).await?;
2999    }
3000    Ok(Redirect::to("/").into_response())
3001}
3002
3003// ---------------------------------------------------------------------------
3004// Subscribe by URL
3005// ---------------------------------------------------------------------------
3006
3007/// Flash for a URL this instance cannot store as a feed — not private, just
3008/// not a kind of feed it supports (an `at://` publication with
3009/// `FEATHERREADER_STANDARD_SITE` off, an unsupported scheme). Distinct from
3010/// [`PRIVATE_FEED_REFUSAL`], whose "not saved or sent anywhere" would be a
3011/// false promise for a record that may already exist in the user's PDS.
3012const UNSUPPORTED_FEED_URL_REFUSAL: &str =
3013    "That isn't a kind of feed this instance can subscribe to. Nothing was saved.";
3014
3015/// Shown when an OPML export is refused because the subscription list could not
3016/// be read in full.
3017///
3018/// **An empty export is worse than no export.** This path used to
3019/// `unwrap_or_default()`, so a failed read produced a 200 carrying a zero-feed
3020/// file — a blank backup, handed over at the moment the reader reached for one.
3021const EXPORT_INCOMPLETE_REFUSAL: &str =
3022    "Could not read your subscriptions in full, so nothing was exported. Your \
3023     feeds are unchanged — try again, and if it keeps failing the list may be \
3024     larger than this reader can page through.";
3025
3026/// Refusal message shown when a private/paid feed is submitted. FeatherReader
3027/// stores subscriptions in the user's PUBLIC PDS, so it supports public feeds
3028/// only for now — a private feed's secret URL is never saved, fetched, or sent
3029/// anywhere. Kept as a constant so the add and OPML paths share the exact wording
3030/// and the boot-smoke can assert on it.
3031const PRIVATE_FEED_REFUSAL: &str = "Private/paid feeds aren't supported yet. \
3032    FeatherReader stores your subscriptions in your public PDS, so it supports public \
3033    feeds for now — private-feed support arrives when atproto's private data \
3034    (permissioned records) ships. Your feed URL was not saved or sent anywhere.";
3035
3036/// Form body for `POST /subscriptions`.
3037#[derive(Debug, Deserialize)]
3038struct SubscribeForm {
3039    url: String,
3040    /// Optional folder `at://` URI to file the new feed under.
3041    #[serde(default)]
3042    folder: Option<String>,
3043}
3044
3045/// The DID-form URL to store for a pasted `at://` publication, or the flash
3046/// to refuse it with.
3047///
3048/// - The scheme is canonicalised: `At://` is the same publication, and
3049///   storing a second spelling makes a second row for it (#183).
3050/// - It must name a `site.standard.publication`; anything else is not a feed
3051///   this instance can read.
3052/// - A handle is resolved to its DID: a handle is a mutable name, and
3053///   `feeds.url` is keyed on identity, so only the DID form is stored.
3054async fn publication_url_from_paste(state: &AppState, input: &str) -> Result<String, String> {
3055    let unsupported = || UNSUPPORTED_FEED_URL_REFUSAL.to_string();
3056    let canonical = format!(
3057        "{}{}",
3058        crate::atproto::AT_URI_PREFIX,
3059        &input[crate::atproto::AT_URI_PREFIX.len()..]
3060    );
3061    let uri = crate::standard_site::AtUri::parse(&canonical).ok_or_else(unsupported)?;
3062    if uri.collection != lexicon::nsid::STANDARD_PUBLICATION {
3063        return Err(unsupported());
3064    }
3065    let did = if crate::oauth::identity::is_atproto_did(&uri.authority) {
3066        uri.authority.clone()
3067    } else {
3068        let handle =
3069            // Validated as a handle before it is sent anywhere: an authority
3070            // that is neither a DID nor a handle (`did:plc:TOOSHORT`, an
3071            // uppercase DID, a newline) is unsupported, not a lookup (found in
3072            // review).
3073            crate::oauth::identity::normalize_handle(&uri.authority).map_err(|_| unsupported())?;
3074        crate::atproto::resolve_handle(&state.http, &state.config.resolver_base, &handle)
3075            .await
3076            .map_err(|err| {
3077                warn!(%err, handle = %uri.authority, "could not resolve a pasted publication's handle");
3078                format!("Couldn't resolve the handle {} to an account.", uri.authority)
3079            })?
3080    };
3081    let url = format!(
3082        "{}{did}/{}/{}",
3083        crate::atproto::AT_URI_PREFIX,
3084        uri.collection,
3085        uri.rkey
3086    );
3087    if !feed::is_storable_feed_url(&url, true) {
3088        return Err(unsupported());
3089    }
3090    Ok(url)
3091}
3092
3093/// `POST /subscriptions` — subscribe by URL.
3094async fn add_subscription(
3095    State(state): State<AppState>,
3096    headers: HeaderMap,
3097    Form(form): Form<SubscribeForm>,
3098) -> Result<Response, WebError> {
3099    let did = match current_did(&state, &headers).await {
3100        Some(d) => d,
3101        None => return Ok(Redirect::to("/login").into_response()),
3102    };
3103    let pool = &state.db;
3104    let input = form.url.trim().to_string();
3105    if input.is_empty() {
3106        return Ok(Redirect::to("/").into_response());
3107    }
3108
3109    // Per-DID subscription cap: bound one account's storage/poller footprint on
3110    // the small box. Checked BEFORE any fetch/resolve so an over-cap account
3111    // can't even trigger an outbound request. `<= 0` disables the cap.
3112    let cap = state.config.max_subs_per_did;
3113    if cap > 0 {
3114        match store::count_subscriptions_for_did(pool, &did).await {
3115            Ok(n) if n >= cap => {
3116                info!(%did, current = n, cap, "refused subscribe: per-DID subscription cap reached");
3117                return Ok(Redirect::to(&format!(
3118                    "/?flash={}",
3119                    qenc(&format!(
3120                        "Subscription limit reached ({cap}). Remove a feed before adding another."
3121                    ))
3122                ))
3123                .into_response());
3124            }
3125            Ok(_) => {}
3126            Err(err) => warn!(%err, %did, "could not count subscriptions for cap check; allowing"),
3127        }
3128    }
3129
3130    // **An at:// paste is a standard.site publication, read by the poller
3131    // since 0.4.0**: canonicalised and resolved to its DID form here, then it
3132    // joins the ordinary path below. With the flag off it is refused as it
3133    // always was — the flag gates what may be stored.
3134    let is_at_uri = input
3135        .get(..crate::atproto::AT_URI_PREFIX.len())
3136        .is_some_and(|p| p.eq_ignore_ascii_case(crate::atproto::AT_URI_PREFIX));
3137    let publication_url = if is_at_uri {
3138        if !state.config.standard_site {
3139            info!(url = %input, %did, "refused an at:// paste: standard.site is off (not stored)");
3140            return Ok(
3141                Redirect::to(&format!("/?flash={}", qenc(UNSUPPORTED_FEED_URL_REFUSAL)))
3142                    .into_response(),
3143            );
3144        }
3145        match publication_url_from_paste(&state, &input).await {
3146            Ok(url) => Some(url),
3147            Err(flash) => {
3148                info!(url = %input, %did, %flash, "refused an at:// paste (not stored)");
3149                return Ok(Redirect::to(&format!("/?flash={}", qenc(&flash))).into_response());
3150            }
3151        }
3152    } else {
3153        None
3154    };
3155
3156    if let feed::FeedPrivacy::Private(reason) =
3157        feed::classify_feed_privacy(publication_url.as_deref().unwrap_or(&input))
3158    {
3159        info!(url = %input, %reason, %did, "refused private/paid feed at add (not fetched or stored)");
3160        return Ok(
3161            Redirect::to(&format!("/?flash={}", qenc(PRIVATE_FEED_REFUSAL))).into_response(),
3162        );
3163    }
3164
3165    let resolved = match publication_url {
3166        Some(url) => Ok(url),
3167        None => resolve_feed_url(&state.config, &input).await,
3168    };
3169    let feed_url = match resolved {
3170        Ok(u) => u,
3171        Err(err) => {
3172            warn!(%err, url = %input, "could not resolve a feed from the given URL");
3173            return Ok(Redirect::to(&format!(
3174                "/?flash={}",
3175                qenc("Couldn't find a feed at that URL")
3176            ))
3177            .into_response());
3178        }
3179    };
3180
3181    // Defensive: resolution may have discovered a feed URL that itself carries a
3182    // secret (e.g. a public site page linking a tokened feed). Re-check the
3183    // resolved URL and refuse before storing/writing anything.
3184    if let feed::FeedPrivacy::Private(reason) = feed::classify_feed_privacy(&feed_url) {
3185        info!(url = %feed_url, %reason, %did, "refused private/paid feed after resolution (not stored)");
3186        return Ok(
3187            Redirect::to(&format!("/?flash={}", qenc(PRIVATE_FEED_REFUSAL))).into_response(),
3188        );
3189    }
3190
3191    // The URL about to be STORED is what must be storable — not the one the
3192    // user typed. Autodiscovery already yields only http(s), but this is the
3193    // path that writes the row and the PDS record, so the check lives here too:
3194    // the same gate the OPML and rename paths apply, on the same terms.
3195    if !feed::is_storable_feed_url(&feed_url, state.config.standard_site) {
3196        info!(url = %feed_url, %did, "refused unsupported feed URL after resolution (not stored)");
3197        return Ok(
3198            Redirect::to(&format!("/?flash={}", qenc(UNSUPPORTED_FEED_URL_REFUSAL)))
3199                .into_response(),
3200        );
3201    }
3202
3203    // Global feeds ceiling: a brand-new distinct feed is refused once the shared
3204    // cache is full (an existing/duplicate feed URL is always fine — it adds no
3205    // row). Bounds total cache size across all users on the box. `<= 0` disables.
3206    let feeds_cap = state.config.max_feeds_global;
3207    if feeds_cap > 0 && store::get_feed_by_url(pool, &feed_url).await?.is_none() {
3208        match store::count_feeds(pool).await {
3209            Ok(n) if n >= feeds_cap => {
3210                warn!(%did, feeds = n, cap = feeds_cap, feed = %feed_url, "refused subscribe: global feeds ceiling reached");
3211                return Ok(Redirect::to(&format!(
3212                    "/?flash={}",
3213                    qenc(
3214                        "This instance is at its feed capacity right now. Please try again later."
3215                    )
3216                ))
3217                .into_response());
3218            }
3219            Ok(_) => {}
3220            Err(err) => warn!(%err, "could not count feeds for global-cap check; allowing"),
3221        }
3222    }
3223
3224    store::upsert_feed(
3225        pool,
3226        &store::NewFeed {
3227            url: feed_url.clone(),
3228            ..Default::default()
3229        },
3230    )
3231    .await?;
3232
3233    if let Ok(client) = feed::build_client() {
3234        if let Some(feed_row) = store::get_feed_by_url(pool, &feed_url).await? {
3235            match feed::poll_feed_by_kind(pool, &client, &state.config, &feed_row).await {
3236                Ok(outcome) => {
3237                    info!(feed = %feed_url, ?outcome, "polled new subscription");
3238                    // **This path is not the scheduler, so it must settle the
3239                    // error columns itself.** `poll_feed` writes validators and
3240                    // `last_polled` and nothing else.
3241                    feed::settle_poll(pool, &feed_url, &outcome, state.config.poll_interval).await;
3242                }
3243                Err(err) => warn!(%err, feed = %feed_url, "initial poll failed"),
3244            }
3245        }
3246    }
3247
3248    let mut sub = Subscription::new(feed_url.clone(), now_rfc3339());
3249    if let Ok(Some(feed_row)) = store::get_feed_by_url(pool, &feed_url).await {
3250        sub.title = feed_row.title.clone();
3251        sub.site_url = feed_row.site_url.clone();
3252    }
3253    sub.folder = form
3254        .folder
3255        .map(|f| f.trim().to_string())
3256        .filter(|f| !f.is_empty());
3257
3258    match state.repo().add_subscription(&did, &sub).await {
3259        Ok(rkey) => info!(feed = %feed_url, %rkey, %did, "wrote subscription record to PDS"),
3260        Err(err) => {
3261            warn!(%err, feed = %feed_url, %did, "PDS subscription write failed (cached locally)")
3262        }
3263    }
3264
3265    Ok(Redirect::to("/").into_response())
3266}
3267
3268/// `POST /subscriptions/:rkey/delete` — unsubscribe (delete the PDS record).
3269async fn delete_subscription(
3270    State(state): State<AppState>,
3271    headers: HeaderMap,
3272    Path(rkey): Path<String>,
3273) -> Result<Response, WebError> {
3274    let did = match current_did(&state, &headers).await {
3275        Some(d) => d,
3276        None => return Ok(Redirect::to("/login").into_response()),
3277    };
3278    match state.repo().remove_subscription(&did, &rkey).await {
3279        Ok(()) => info!(%did, %rkey, "unsubscribed (deleted PDS subscription record)"),
3280        Err(err) => warn!(%err, %did, %rkey, "PDS unsubscribe failed"),
3281    }
3282    Ok(Redirect::to("/").into_response())
3283}
3284
3285/// Form body for `POST /subscriptions/:rkey/rename`.
3286#[derive(Debug, Deserialize)]
3287struct RenameSubForm {
3288    url: String,
3289    #[serde(default)]
3290    title: Option<String>,
3291    #[serde(default)]
3292    site_url: Option<String>,
3293    #[serde(default)]
3294    folder: Option<String>,
3295}
3296
3297/// `POST /subscriptions/:rkey/rename` — retitle a feed and/or move it to a
3298/// folder, rewriting the whole subscription record via `putRecord`.
3299async fn rename_subscription(
3300    State(state): State<AppState>,
3301    headers: HeaderMap,
3302    Path(rkey): Path<String>,
3303    Form(form): Form<RenameSubForm>,
3304) -> Result<Response, WebError> {
3305    let did = match current_did(&state, &headers).await {
3306        Some(d) => d,
3307        None => return Ok(Redirect::to("/login").into_response()),
3308    };
3309    let feed_url = form.url.trim().to_string();
3310
3311    // Reject an empty/blank resolved URL — a rename with no usable URL must not
3312    // write a junk row to the cache or a malformed subscription record to the
3313    // PDS (add_subscription refuses an empty input the same way).
3314    if feed_url.is_empty() {
3315        return Ok(Redirect::to("/").into_response());
3316    }
3317
3318    // **Read before write — `update_subscription` is a `putRecord`, and a
3319    // putRecord replaces the WHOLE record** (see its doc on `atproto.rs`).
3320    //
3321    // This used to build a fresh `Subscription::new(feed_url, now_rfc3339())`
3322    // and hand that over, so every field the form does not carry was written
3323    // back as its default. `templates/manage_row.html` posts `url`, `title` and
3324    // `folder` — and nothing else — so a rename silently destroyed four fields:
3325    // `siteUrl`, `fetchHint`, `private`, and `createdAt`.
3326    //
3327    // `createdAt` is the one that matters most: it is the reader's subscribe
3328    // time, it is the sort key for "when did I subscribe", it lives in THEIR
3329    // repo rather than our cache, and once overwritten it is gone with nothing
3330    // in the UI to say so.
3331    //
3332    // There is no single-record read on `Repo` (no `getRecord`), so this lists
3333    // and filters. That is one extra round trip on an action that is already
3334    // doing a PDS write, and it is bounded; a `get_subscription` would be
3335    // strictly better if this ever measures badly.
3336    //
3337    // **A failed read refuses the rename.** Falling back to the old
3338    // rebuild-from-scratch here would reinstate the data loss on exactly the
3339    // flaky path, which is the worst place to have it. The write below already
3340    // takes this stance — "a failure here means nothing was renamed or moved" —
3341    // and the read gets the same one.
3342    let existing = match state.repo().list_subscriptions_sorted(&did).await {
3343        Ok(subs) => subs.into_iter().find(|(k, _)| *k == rkey).map(|(_, s)| s),
3344        Err(err) => {
3345            warn!(%err, %did, %rkey, "could not read the subscription before renaming it");
3346            return Ok(Redirect::to(&format!(
3347                "/?flash={}",
3348                qenc("Could not reach your PDS — nothing was renamed or moved.")
3349            ))
3350            .into_response());
3351        }
3352    };
3353    let Some(existing) = existing else {
3354        // The rkey is not in the reader's repo. Renaming a record that is not
3355        // there would CREATE one, which is not what "rename" means and would
3356        // give it a fresh `createdAt` — the bug this read exists to prevent.
3357        warn!(%did, %rkey, "refused rename: no such subscription in the repo");
3358        return Ok(Redirect::to(&format!(
3359            "/?flash={}",
3360            qenc("That subscription is no longer in your repo — nothing was renamed or moved.")
3361        ))
3362        .into_response());
3363    };
3364
3365    // The subscription can be repointed at a different feed URL. **Every gate
3366    // on the URL applies to a repoint and only a repoint** — the three below
3367    // were each, at one time, run before this line on the URL as posted, and
3368    // each refused a pure retitle of a record that already existed:
3369    //
3370    // - privacy: the narrowed at:// arm fails closed as `Private` for an
3371    //   at-URI that is not a publication (a feed generator another client
3372    //   subscribed to), so the record became un-editable with a flash saying
3373    //   it "was not saved or sent anywhere";
3374    // - the global feeds ceiling keyed on "URL not in the cache", and an
3375    //   at:// record is never cached with the flag off, so at capacity a
3376    //   retitle was refused for a row the handler would not insert;
3377    // - storability, the same way.
3378    //
3379    // An unchanged URL is already in the reader's repo; refusing to retitle
3380    // it protects nothing and takes their own record away from them.
3381    // Like for like: the form value is trimmed, and a record another client
3382    // wrote may carry padding — compared raw, every retitle of it was a repoint.
3383    let url_changed = existing.url.trim() != feed_url;
3384
3385    // **Storability, on the same terms as the add and OPML paths — for a
3386    // REPOINT, and FIRST.** A target this instance cannot store gets that
3387    // answer, not "private" (the at:// arm fails closed) or "at capacity"
3388    // (it would never be inserted) — the ordering the add path has. This handler writes `feeds` via `upsert_feed` and had only the
3389    // privacy check above, so `FEATHERREADER_STANDARD_SITE` was bypassable
3390    // here; a review found it by enumerating every writer of the table. The
3391    // first fix ran this check before the repo lookup, on the URL as posted —
3392    // which refused a pure retitle of a subscription that already IS an
3393    // at-URI, on every instance with the flag off. The flag gates what the
3394    // cache may store, not whether a reader may edit their own record: an
3395    // unchanged non-storable URL keeps its PDS write and simply gets no cache
3396    // row below.
3397    let storable = feed::is_storable_feed_url(&feed_url, state.config.standard_site);
3398    if url_changed && !storable {
3399        info!(url = %feed_url, %did, %rkey, "refused a repoint to a non-storable feed URL");
3400        return Ok(
3401            Redirect::to(&format!("/?flash={}", qenc(UNSUPPORTED_FEED_URL_REFUSAL)))
3402                .into_response(),
3403        );
3404    }
3405
3406    // Block private/paid feeds on a repoint. `url` is attacker-controllable,
3407    // and rename both upserts it to the local cache AND rewrites the PDS
3408    // subscription record (a public `putRecord`), so without this guard a
3409    // crafted rename could land a secret-bearing URL in the public PDS — the
3410    // exact leak the add and OPML paths already prevent.
3411    if url_changed {
3412        if let feed::FeedPrivacy::Private(reason) = feed::classify_feed_privacy(&feed_url) {
3413            info!(url = %feed_url, %reason, %did, %rkey, "refused private/paid feed at rename (not stored or written)");
3414            return Ok(
3415                Redirect::to(&format!("/?flash={}", qenc(PRIVATE_FEED_REFUSAL))).into_response(),
3416            );
3417        }
3418    }
3419
3420    // Global feeds ceiling parity with add_subscription: a repoint to a
3421    // brand-new feed URL would insert a NEW `feeds` row. Refuse that when the
3422    // shared cache is at capacity (an existing/duplicate URL adds no row and
3423    // is always fine). `<= 0` disables.
3424    let feeds_cap = state.config.max_feeds_global;
3425    if url_changed
3426        && feeds_cap > 0
3427        && store::get_feed_by_url(&state.db, &feed_url)
3428            .await?
3429            .is_none()
3430    {
3431        match store::count_feeds(&state.db).await {
3432            Ok(n) if n >= feeds_cap => {
3433                warn!(%did, %rkey, feeds = n, cap = feeds_cap, feed = %feed_url, "refused rename: global feeds ceiling reached");
3434                return Ok(Redirect::to(&format!(
3435                    "/?flash={}",
3436                    qenc(
3437                        "This instance is at its feed capacity right now. Please try again later."
3438                    )
3439                ))
3440                .into_response());
3441            }
3442            Ok(_) => {}
3443            Err(err) => warn!(%err, "could not count feeds for global-cap check; allowing"),
3444        }
3445    }
3446
3447    let mut sub = existing;
3448    sub.url = feed_url;
3449    sub.title = form
3450        .title
3451        .map(|t| t.trim().to_string())
3452        .filter(|t| !t.is_empty());
3453    sub.folder = form
3454        .folder
3455        .map(|f| f.trim().to_string())
3456        .filter(|f| !f.is_empty());
3457    // `createdAt` and `private` carry over untouched — neither is a property of
3458    // which feed URL the subscription points at.
3459    //
3460    // `siteUrl` and `fetchHint` ARE properties of the specific feed, so a
3461    // repoint drops them rather than leaving a site link for the old feed
3462    // hanging off the new one. An explicit form value still wins if the form
3463    // ever starts carrying one.
3464    match form
3465        .site_url
3466        .map(|t| t.trim().to_string())
3467        .filter(|t| !t.is_empty())
3468    {
3469        Some(site) => sub.site_url = Some(site),
3470        None if url_changed => sub.site_url = None,
3471        None => {}
3472    }
3473    if url_changed {
3474        sub.fetch_hint = None;
3475    }
3476
3477    // Keep the local cache title in step for the loose-feed fallback path —
3478    // for a row this instance would have. Two cases write nothing:
3479    //
3480    // - not storable (an existing at-URI with the flag off): the record is the
3481    //   reader's to edit, the cache row is not this instance's to create;
3482    // - an unchanged URL with no cache row: a retitle is never the write that
3483    //   CREATES a row. That covers two findings at once — the ceiling is
3484    //   checked on a repoint only, so a retitle must not insert past it; and
3485    //   a secret-bearing URL another client subscribed to has no row (the
3486    //   privacy gate above runs on a repoint only, and `resolve_subscriptions`
3487    //   refuses to cache it), so it cannot enter the shared table here, be
3488    //   polled, fail, and be printed on the admin page. A privacy re-check on
3489    //   this write was the first draft; mutation showed it dead — the row
3490    //   rule already refused every case it would have.
3491    let cache_write =
3492        storable && (url_changed || store::get_feed_by_url(&state.db, &sub.url).await?.is_some());
3493    if !cache_write {
3494        info!(%did, %rkey, url = %sub.url, "renamed a subscription without touching the cache");
3495    } else if let Err(err) = store::upsert_feed(
3496        &state.db,
3497        &store::NewFeed {
3498            url: sub.url.clone(),
3499            title: sub.title.clone(),
3500            site_url: sub.site_url.clone(),
3501            ..Default::default()
3502        },
3503    )
3504    .await
3505    {
3506        // Not fatal to the rename — the PDS record below is the source of truth
3507        // — but a missing `feeds` row means this subscription is never polled.
3508        warn!(%err, %did, url = %sub.url, "could not update the cached feed row on rename");
3509    }
3510
3511    // **The PDS write decides what the reader is told.**
3512    //
3513    // This used to `warn!` on failure and then redirect exactly as it does on
3514    // success, so a rename that did not happen was indistinguishable from one
3515    // that did — the reader saw their old title come back and had no reason to
3516    // think anything had gone wrong. The PDS record IS the subscription; a
3517    // failure here means nothing was renamed or moved.
3518    match state.repo().update_subscription(&did, &rkey, &sub).await {
3519        Ok(res) => {
3520            info!(%did, %rkey, uri = %res.uri, "renamed/moved subscription");
3521            Ok(Redirect::to("/").into_response())
3522        }
3523        Err(err) => {
3524            warn!(%err, %did, %rkey, "PDS subscription update failed");
3525            Ok(Redirect::to(&format!(
3526                "/?flash={}",
3527                qenc("Could not save that change to your PDS — nothing was renamed or moved.")
3528            ))
3529            .into_response())
3530        }
3531    }
3532}
3533
3534// ---------------------------------------------------------------------------
3535// Folders
3536// ---------------------------------------------------------------------------
3537
3538/// Form body for `POST /folders`.
3539#[derive(Debug, Deserialize)]
3540struct FolderForm {
3541    name: String,
3542}
3543
3544/// `POST /folders` — create a folder record.
3545async fn create_folder(
3546    State(state): State<AppState>,
3547    headers: HeaderMap,
3548    Form(form): Form<FolderForm>,
3549) -> Result<Response, WebError> {
3550    let did = match current_did(&state, &headers).await {
3551        Some(d) => d,
3552        None => return Ok(Redirect::to("/login").into_response()),
3553    };
3554    let name = form.name.trim();
3555    if name.is_empty() {
3556        return Ok(Redirect::to("/").into_response());
3557    }
3558    let folder = Folder::new(name.to_string(), now_rfc3339());
3559    match state.repo().add_folder(&did, &folder).await {
3560        Ok(rkey) => info!(%did, %rkey, name, "created folder record"),
3561        Err(err) => warn!(%err, %did, "PDS folder create failed"),
3562    }
3563    Ok(Redirect::to("/").into_response())
3564}
3565
3566/// `POST /folders/:rkey/rename` — rename a folder record.
3567async fn rename_folder(
3568    State(state): State<AppState>,
3569    headers: HeaderMap,
3570    Path(rkey): Path<String>,
3571    Form(form): Form<FolderForm>,
3572) -> Result<Response, WebError> {
3573    let did = match current_did(&state, &headers).await {
3574        Some(d) => d,
3575        None => return Ok(Redirect::to("/login").into_response()),
3576    };
3577    let name = form.name.trim();
3578    if name.is_empty() {
3579        return Ok(Redirect::to("/").into_response());
3580    }
3581    let folder = Folder::new(name.to_string(), now_rfc3339());
3582    match state.repo().rename_folder(&did, &rkey, &folder).await {
3583        Ok(res) => info!(%did, %rkey, uri = %res.uri, "renamed folder"),
3584        Err(err) => warn!(%err, %did, %rkey, "PDS folder rename failed"),
3585    }
3586    Ok(Redirect::to("/").into_response())
3587}
3588
3589/// `POST /folders/:rkey/delete` — delete a folder record (feeds referencing it
3590/// simply become un-foldered).
3591async fn delete_folder(
3592    State(state): State<AppState>,
3593    headers: HeaderMap,
3594    Path(rkey): Path<String>,
3595) -> Result<Response, WebError> {
3596    let did = match current_did(&state, &headers).await {
3597        Some(d) => d,
3598        None => return Ok(Redirect::to("/login").into_response()),
3599    };
3600    match state.repo().remove_folder(&did, &rkey).await {
3601        Ok(()) => info!(%did, %rkey, "deleted folder record"),
3602        Err(err) => warn!(%err, %did, %rkey, "PDS folder delete failed"),
3603    }
3604    Ok(Redirect::to("/").into_response())
3605}
3606
3607/// Resolve a user-pasted URL to a canonical feed URL: if fetching it yields a
3608/// feed document we take it as-is; if it yields an HTML page we run
3609/// autodiscovery over its `<link rel="alternate">` tags.
3610async fn resolve_feed_url(_config: &Config, input: &str) -> anyhow::Result<String> {
3611    let parsed =
3612        url::Url::parse(input).map_err(|e| anyhow::anyhow!("not a valid URL {input:?}: {e}"))?;
3613
3614    let client = feed::build_client()?;
3615    // Fetch through the SSRF guard: scheme + resolved-IP checks on the URL and
3616    // every redirect hop, so a user-pasted URL can't reach cloud metadata /
3617    // loopback / private hosts.
3618    let resp = crate::net::guarded_get(&client, parsed.as_str(), &[]).await?;
3619    let final_url = resp.url().clone();
3620    let content_type = resp
3621        .headers()
3622        .get(axum::http::header::CONTENT_TYPE)
3623        .and_then(|v| v.to_str().ok())
3624        .unwrap_or("")
3625        .to_ascii_lowercase();
3626    // Cap the body (streamed, aborts over 8 MiB) — never trust Content-Length,
3627    // gzip strips it, and this response is reflected into the UI.
3628    let raw = crate::net::read_capped(resp).await?;
3629    let body = String::from_utf8_lossy(&raw).into_owned();
3630
3631    let looks_like_feed = content_type.contains("xml")
3632        || content_type.contains("rss")
3633        || content_type.contains("atom")
3634        || content_type.contains("application/feed+json")
3635        || {
3636            let head = body.trim_start();
3637            head.starts_with("<?xml")
3638                || head.starts_with("<rss")
3639                || head.starts_with("<feed")
3640                || head.contains("<rss")
3641                || head.contains("<feed")
3642        };
3643    if looks_like_feed {
3644        return Ok(final_url.to_string());
3645    }
3646
3647    match feed::discover_feed(&body, Some(&final_url)) {
3648        Some(u) => Ok(u.to_string()),
3649        None => anyhow::bail!("no feed found at {input} (no autodiscovery link)"),
3650    }
3651}
3652
3653// ---------------------------------------------------------------------------
3654// Login (atproto OAuth via the sidecar)
3655// ---------------------------------------------------------------------------
3656
3657/// Query for `GET /login`.
3658#[derive(Debug, Deserialize, Default)]
3659struct LoginQuery {
3660    #[serde(default)]
3661    handle: Option<String>,
3662    #[serde(default)]
3663    error: Option<String>,
3664    #[serde(default)]
3665    flash: Option<String>,
3666}
3667
3668/// `GET /login` — start the atproto OAuth flow, or render the handle form.
3669///
3670/// **Pre-handshake gate:** starting OAuth (a `?handle=` GET) is refused unless
3671/// the visitor is allowed by [`may_start_oauth`] — an existing beta seat (via
3672/// session cookie *or* the submitted handle resolving to a seated DID) or a
3673/// valid reserving invite cookie. Refusal redirects to `/beta/redeem`. The bare
3674/// form (no handle) always renders.
3675async fn login_form(
3676    State(state): State<AppState>,
3677    headers: HeaderMap,
3678    Query(q): Query<LoginQuery>,
3679) -> Response {
3680    if let Some(handle) = q
3681        .handle
3682        .map(|h| h.trim().to_string())
3683        .filter(|h| !h.is_empty())
3684    {
3685        if !may_start_oauth(&state, &headers, &handle).await {
3686            return Redirect::to("/beta/redeem").into_response();
3687        }
3688        return start_oauth(&state, &handle).await;
3689    }
3690    render(&LoginTemplate {
3691        repo_url: REPO_URL,
3692        error: q.error.unwrap_or_default(),
3693        flash: q.flash.unwrap_or_default(),
3694    })
3695}
3696
3697/// `POST /login` — the handle-form submit: redirect into the sidecar OAuth flow.
3698/// Subject to the same pre-handshake invite gate as `GET /login?handle=`.
3699async fn login_submit(
3700    State(state): State<AppState>,
3701    headers: HeaderMap,
3702    Form(form): Form<LoginForm>,
3703) -> Response {
3704    let handle = form.handle.trim();
3705    if handle.is_empty() {
3706        return login_error("Enter your atproto handle.");
3707    }
3708    if !may_start_oauth(&state, &headers, handle).await {
3709        return Redirect::to("/beta/redeem").into_response();
3710    }
3711    start_oauth(&state, handle).await
3712}
3713
3714/// Whether this visitor is allowed to *start* the OAuth handshake. The gate
3715/// admits, in order of cost:
3716///
3717/// 1. an existing beta member's cookie session whose DID already holds a seat;
3718/// 2. a fresh visitor carrying a valid reserving invite cookie;
3719/// 3. a cookie-less visitor whose submitted `handle` resolves to a DID that
3720///    already holds a seat — this honors the **seeded admin's first login** on a
3721///    fresh deploy (and any returning member who cleared cookies) without a
3722///    session cookie or an invite code.
3723///
3724/// The cookie/invite fast paths run FIRST and short-circuit, so the network
3725/// handle→DID resolution is only attempted when neither applies. It fails
3726/// CLOSED: a malformed/unresolvable handle, a resolution error/timeout, or a
3727/// resolved DID with no seat all leave the visitor bounced to `/beta/redeem`.
3728/// This keeps the anti-abuse intent — a rando now pays a cheap handle
3729/// resolution instead of a burned sidecar handshake (and `/login` is already in
3730/// the rate-limited path set).
3731async fn may_start_oauth(state: &AppState, headers: &HeaderMap, handle: &str) -> bool {
3732    // The production resolver is the app's existing atproto handle→DID path,
3733    // routed through the SSRF guard. Resolution is injected so tests can exercise
3734    // the gate without a live network call (the guard forbids loopback mocks).
3735    may_start_oauth_with(state, headers, handle, |h| async move {
3736        crate::atproto::resolve_handle(&state.http, &state.config.resolver_base, &h)
3737            .await
3738            .ok()
3739    })
3740    .await
3741}
3742
3743/// Core of [`may_start_oauth`] with the handle→DID resolver injected as `resolve`
3744/// (returning `Some(did)` on success, `None` on any failure/unresolvable handle).
3745/// The cookie + invite fast paths run FIRST and short-circuit, so `resolve` is
3746/// only called when neither admits — keeping the network round-trip off the hot
3747/// path and preserving the fail-closed contract on resolution failure.
3748async fn may_start_oauth_with<F, Fut>(
3749    state: &AppState,
3750    headers: &HeaderMap,
3751    handle: &str,
3752    resolve: F,
3753) -> bool
3754where
3755    F: FnOnce(String) -> Fut,
3756    Fut: std::future::Future<Output = Option<String>>,
3757{
3758    // 1. An already-beta'd session may re-auth freely.
3759    if let Some(did) = current_did(state, headers).await {
3760        if store::has_beta_access(&state.db, &did)
3761            .await
3762            .unwrap_or(false)
3763        {
3764            return true;
3765        }
3766    }
3767    // 2. A valid reserving invite cookie.
3768    if invite_cookie_code(headers, &state.config.cookie_secret).is_some() {
3769        return true;
3770    }
3771    // 3. Cookie-less: honor an existing seat by resolving the submitted handle to
3772    //    a DID (the seeded-admin first-login / cleared-cookies case). Fail closed
3773    //    on any resolution error or unresolvable/malformed handle.
3774    match resolve(handle.to_string()).await {
3775        Some(did) => store::has_beta_access(&state.db, &did)
3776            .await
3777            .unwrap_or(false),
3778        None => {
3779            warn!(%handle, "handle resolution failed in pre-handshake beta gate");
3780            false
3781        }
3782    }
3783}
3784
3785/// Begin the OAuth handshake for `handle`, on whichever backend is live.
3786///
3787/// **On `form-action 'self'` and this redirect.** The Rust arm answers a form
3788/// POST with a redirect straight to the PDS — cross-origin — while the app's CSP
3789/// carries `form-action 'self'`. Browsers have historically disagreed about
3790/// whether that directive applies to redirects following a form submission, and
3791/// if it did here, login would break in a browser while every test passed.
3792///
3793/// It does not, and the evidence is the SIDECAR path, which is live in
3794/// production today: `POST /login` -> 303 to the same-origin `/oauth/login` ->
3795/// 302 to the PDS, cross-origin, under this same CSP. A browser checking the
3796/// whole redirect chain would already be blocking that. One checking only the
3797/// form's action URL sees `/login` in both cases. The two arms differ only in
3798/// how many same-origin hops precede the cross-origin one, so any policy that
3799/// permits the sidecar flow permits this one.
3800///
3801/// The two arms differ in SHAPE, not just in implementation. The sidecar owns
3802/// its own `/login` and its own callback, so starting a login is one redirect
3803/// and nothing is stored here. The Rust backend pushes the authorization
3804/// request itself, which means this app now holds the pending login — and must
3805/// set the browser-binding cookie that the callback will be checked against.
3806async fn start_oauth(state: &AppState, handle: &str) -> Response {
3807    match state.config.repo_backend {
3808        crate::metrics::Backend::Sidecar => {
3809            let url = state.sidecar.login_url(handle, None);
3810            info!(%handle, "redirecting to OAuth sidecar login");
3811            Redirect::to(&url).into_response()
3812        }
3813        crate::metrics::Backend::Rust => {
3814            let Some(runtime) = state.oauth.as_deref() else {
3815                warn!("the rust backend is live but its OAuth runtime is absent");
3816                return login_error("Login is not available right now.");
3817            };
3818            match crate::oauth::login::start(
3819                runtime,
3820                &state.http,
3821                &state.db,
3822                handle,
3823                crate::store::now_unix(),
3824            )
3825            .await
3826            {
3827                Ok(started) => {
3828                    info!(%handle, "pushed authorization request; redirecting to the PDS");
3829                    let mut resp = Redirect::to(&started.authorize_url).into_response();
3830                    set_cookie(
3831                        &mut resp,
3832                        &cookie::sign_value(
3833                            OAUTH_BINDING_COOKIE,
3834                            &started.binding_token,
3835                            &state.config.cookie_secret,
3836                            OAUTH_BINDING_MAX_AGE_SECS,
3837                        ),
3838                    );
3839                    resp
3840                }
3841                Err(err) => {
3842                    // The handle the user typed is logged; the error is not shown
3843                    // to them verbatim, since it can name internal hosts.
3844                    warn!(%err, %handle, "could not start the OAuth login");
3845                    login_error("Could not start login for that handle.")
3846                }
3847            }
3848        }
3849    }
3850}
3851
3852/// Clear the browser-binding cookie. Called on every terminal outcome of a
3853/// callback, successful or not: the pending row is consumed either way, so a
3854/// lingering cookie can only ever match a login that no longer exists.
3855fn clear_binding_cookie(resp: &mut Response) {
3856    set_cookie(
3857        resp,
3858        &format!("{OAUTH_BINDING_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
3859    );
3860}
3861
3862/// Form body for `POST /login`.
3863#[derive(Debug, Deserialize)]
3864struct LoginForm {
3865    handle: String,
3866}
3867
3868/// Query for `GET /oauth/callback`.
3869///
3870/// Carries BOTH shapes, because the two backends deliver different things to
3871/// the same URL: the sidecar hands back a one-shot `session_id` it has already
3872/// exchanged, while the PDS redirects here directly with `code`/`state`/`iss`
3873/// for this app to exchange itself. Which fields are populated is decided by
3874/// which backend started the login, not by which is live now — so a flip with a
3875/// login already in flight still lands in the right arm.
3876#[derive(Debug, Deserialize, Default)]
3877struct CallbackQuery {
3878    /// Sidecar backend: the handoff id.
3879    #[serde(default)]
3880    session_id: Option<String>,
3881    /// Rust backend: the authorization code and its envelope.
3882    #[serde(default)]
3883    code: Option<String>,
3884    #[serde(default)]
3885    state: Option<String>,
3886    #[serde(default)]
3887    iss: Option<String>,
3888    /// JARM, which is not supported — carried only so it can be refused
3889    /// explicitly rather than read as "no code".
3890    #[serde(default)]
3891    response: Option<String>,
3892    #[serde(default)]
3893    error: Option<String>,
3894    #[serde(default)]
3895    error_description: Option<String>,
3896}
3897
3898/// `GET /oauth/callback` — establish the cookie session.
3899///
3900/// **Invite gate:** the verified DID must hold beta access. If it already does
3901/// (existing member / seeded admin) it's admitted directly. Otherwise we bind
3902/// the DID to the reserved invite cookie: `redeem_code` atomically consumes the
3903/// code and grants the seat. A DID with neither is bounced to `/beta/redeem`.
3904async fn oauth_callback(
3905    State(state): State<AppState>,
3906    headers: HeaderMap,
3907    Query(q): Query<CallbackQuery>,
3908) -> Response {
3909    // An error response is handled by the SAME arm that would have handled a
3910    // success, not short-circuited here.
3911    //
3912    // Returning early looks obviously right and is wrong on the Rust path: it
3913    // skips `verify_callback`, which validates `iss` BEFORE reporting the error
3914    // precisely because RFC 9207 §2.4 says a client "MUST NOT assume that the
3915    // error originates from the intended AS". It also leaves the pending row
3916    // unconsumed, so a `state` that has already produced a callback stays usable
3917    // until it expires.
3918    //
3919    // The sidecar arm has no such check to reach, so it is short-circuited
3920    // below, preserving exactly what it did before.
3921    // **The arm is chosen by what the SERVER knows, not by what the caller
3922    // sent.** A `session_id` in the query used to select the sidecar arm on its
3923    // own — so a caller could pick which code path ran, and the sidecar arm has
3924    // no browser-binding check at all. It also short-circuited the error path
3925    // below, skipping the `iss` validation.
3926    //
3927    // Requiring the Rust runtime to be absent, or a sidecar backend to be the
3928    // configured one, means the selection follows this deployment's own
3929    // configuration. A login started before a flip still completes, because the
3930    // Rust arm is reached whenever the Rust runtime exists and can match the
3931    // `state` against a pending row it actually wrote.
3932    // The sidecar hands off in TWO shapes, not one: `?session_id=…` on success
3933    // and `?error=…&error_description=…` on its own failure. Keying only on
3934    // `session_id` sent the failure shape down the Rust arm, which then failed
3935    // with "no `state`" and replaced the specific reason with a generic one —
3936    // and `error_description` is exactly what the sidecar Caddy routing matches
3937    // to send that request here in the first place.
3938    let sidecar_shape =
3939        q.session_id.as_deref().is_some_and(|s| !s.is_empty()) || q.error_description.is_some();
3940    let sidecar_handoff = sidecar_shape
3941        && (state.oauth.is_none() || state.config.repo_backend == crate::metrics::Backend::Sidecar);
3942    if let Some(err) = q.error.clone() {
3943        // **Neither the code nor the description is echoed as sent.**
3944        //
3945        // Both are server-controlled free text arriving on a public GET, so
3946        // anyone who can make a browser fetch this URL chooses them. The raw
3947        // `error` used to go into a `warn!` AND into the rendered login page,
3948        // and `error_description` — arbitrary text, newlines included — went
3949        // into the log verbatim: a log-injection surface on one side and
3950        // attacker-chosen copy in the product's own voice on the other.
3951        //
3952        // `oauth::flow` already decided this exact question for the Rust arm:
3953        // reduce the code to a known slug, drop the description entirely. That
3954        // reasoning is not specific to which arm handles the callback, and this
3955        // one simply never got the same treatment. The description's LENGTH is
3956        // kept, because "the server sent a 4 KB explanation" is occasionally
3957        // worth knowing and cannot be used to inject anything.
3958        let slug = crate::oauth::flow::known_error_slug(&err);
3959        warn!(
3960            error = slug,
3961            desc_len = q.error_description.as_deref().map_or(0, str::len),
3962            "OAuth callback returned an error"
3963        );
3964        if sidecar_handoff || state.oauth.is_none() {
3965            return login_error(&format!("Login failed: {slug}"));
3966        }
3967        // Fall through: the Rust arm consumes the pending row and validates
3968        // `iss` against it, and reports the failure afterwards.
3969    }
3970
3971    // Which arm runs is decided by WHAT ARRIVED, not by which backend is
3972    // currently selected: a login started before a flip must still complete.
3973    let session = if sidecar_handoff {
3974        let session_id = q.session_id.clone().unwrap_or_default();
3975        match state.sidecar.resolve_session(&session_id).await {
3976            Ok(Some(s)) => s,
3977            Ok(None) => {
3978                warn!("OAuth callback session_id did not resolve (expired/unknown)");
3979                return login_error("Login session expired — please try again.");
3980            }
3981            Err(err) => {
3982                warn!(%err, "failed to resolve OAuth session via the sidecar");
3983                return login_error("Login failed talking to the auth service.");
3984            }
3985        }
3986    } else {
3987        let Some(runtime) = state.oauth.as_deref() else {
3988            warn!("an OAuth callback arrived with no sidecar session and no Rust runtime");
3989            return login_error("Login failed: this login could not be completed.");
3990        };
3991        let params = crate::oauth::flow::CallbackParams {
3992            code: q.code.clone(),
3993            state: q.state.clone(),
3994            iss: q.iss.clone(),
3995            // Passed through, NOT dropped: `verify_callback` checks `iss`
3996            // against the pending row's issuer before it reports the error, and
3997            // it cannot do that for an error it never sees.
3998            error: q.error.clone(),
3999            error_description: q.error_description.clone(),
4000            response: q.response.clone(),
4001        };
4002        let binding =
4003            cookie::verify_value(&headers, OAUTH_BINDING_COOKIE, &state.config.cookie_secret);
4004        match crate::oauth::login::complete(
4005            runtime,
4006            &state.http,
4007            &state.db,
4008            &params,
4009            binding.as_deref(),
4010            crate::store::now_unix(),
4011        )
4012        .await
4013        {
4014            Ok(done) => crate::atproto::SidecarSession {
4015                did: done.did,
4016                handle: done.handle,
4017            },
4018            Err(err) => {
4019                // Never echoed to the browser: the message can name the issuer,
4020                // the PDS, and why a binding check failed.
4021                warn!(%err, "could not complete the OAuth callback");
4022                let mut resp = login_error("Login failed — please try again.");
4023                clear_binding_cookie(&mut resp);
4024                return resp;
4025            }
4026        }
4027    };
4028
4029    // Bind the verified DID to the invite gate. Returns a response only on the
4030    // (rare) failure paths; `Ok(())` means the DID now holds beta access.
4031    let mut clear_invite = false;
4032    if !store::has_beta_access(&state.db, &session.did)
4033        .await
4034        .unwrap_or(false)
4035    {
4036        // Not yet a member: consume the reserved invite code, if any.
4037        let code = match invite_cookie_code(&headers, &state.config.cookie_secret) {
4038            Some(c) => c,
4039            None => {
4040                warn!(did = %session.did, "OAuth callback with no beta access and no invite cookie");
4041                return Redirect::to("/beta/redeem").into_response();
4042            }
4043        };
4044        match store::redeem_code(
4045            &state.db,
4046            &code,
4047            &session.did,
4048            session.handle.as_deref(),
4049            state.config.beta_cap,
4050        )
4051        .await
4052        {
4053            Ok(Ok(())) => {
4054                clear_invite = true;
4055                info!(did = %session.did, "invite code redeemed at OAuth callback; beta access granted");
4056            }
4057            Ok(Err(policy)) => {
4058                warn!(did = %session.did, ?policy, "invite redeem failed at callback");
4059                let mut resp = redeem_bounce(&policy).into_response();
4060                // The reservation is spent/invalid — drop the stale invite cookie.
4061                clear_invite_cookie(&mut resp);
4062                return resp;
4063            }
4064            Err(err) => {
4065                warn!(%err, did = %session.did, "invite redeem infra error at callback");
4066                return login_error("Login failed while confirming your invite.");
4067            }
4068        }
4069    }
4070
4071    // Mint an opaque, random server-side session id and store the identity under
4072    // it; the cookie carries the (HMAC-signed) sid, never the DID.
4073    let sid = state.sessions.create(Session {
4074        did: session.did.clone(),
4075        handle: session.handle.clone(),
4076    });
4077    let cookie = cookie::sign_session(&sid, &state.config.cookie_secret);
4078    info!(did = %session.did, handle = ?session.handle, "OAuth login OK; session cookie set");
4079
4080    let mut resp = Redirect::to("/").into_response();
4081    set_cookie(&mut resp, &cookie);
4082    clear_binding_cookie(&mut resp);
4083    if clear_invite {
4084        clear_invite_cookie(&mut resp);
4085    }
4086    resp
4087}
4088
4089/// Revoke a DID's OAuth session on BOTH backends, best-effort.
4090///
4091/// Not "whichever backend is live": during a cutover a user's tokens can be in
4092/// either store — they logged in under one backend and are logging out under
4093/// the other. Revoking only the live one would leave a live refresh token
4094/// behind in the other, which is the exact failure sign-out exists to prevent,
4095/// and it would be invisible because the sign-out itself looks successful.
4096///
4097/// Both arms are best-effort. The caller has already decided to sign the user
4098/// out, and a network failure must not trap them in a half-logged-out state.
4099/// How long sign-out will wait for a final read-state flush before revoking
4100/// anyway.
4101///
4102/// Bounded because the flush talks to the user's PDS, and a user trying to leave
4103/// must never be held by a server that is not answering. Three seconds is long
4104/// enough for a healthy `applyWrites` (the production samples run 100–970 ms)
4105/// and short enough that a dead PDS is an inconvenience rather than a trap.
4106const SIGN_OUT_FLUSH_BUDGET: std::time::Duration = std::time::Duration::from_secs(3);
4107
4108/// Flush whatever read-state is still dirty for `did`, then give up quietly.
4109///
4110/// **Called before revoking, because revoking first strands it (#117).**
4111/// `revoke_everywhere` deletes the OAuth session, and a dirty cursor with no
4112/// session cannot be sent by anyone — it parks until the user signs in again,
4113/// which may be never. Flushing first is what stops the common case from
4114/// becoming that.
4115///
4116/// Best-effort by construction: every failure path here falls through to the
4117/// revoke. A flush that times out or errors leaves the cursors dirty, which is
4118/// the parked state the flusher now handles deliberately rather than retrying
4119/// forever.
4120async fn flush_before_revoke(state: &AppState, did: &str) {
4121    match tokio::time::timeout(
4122        SIGN_OUT_FLUSH_BUDGET,
4123        crate::readstate::flush_did(state, did),
4124    )
4125    .await
4126    {
4127        Ok(Ok(())) => {}
4128        Ok(Err(err)) => {
4129            warn!(%did, %err, "sign-out: final read-state flush failed; it will park until next sign-in")
4130        }
4131        Err(_) => warn!(
4132            %did,
4133            budget = ?SIGN_OUT_FLUSH_BUDGET,
4134            "sign-out: final read-state flush timed out; it will park until next sign-in"
4135        ),
4136    }
4137}
4138
4139async fn revoke_everywhere(state: &AppState, did: &str) {
4140    // **Counted under Backend::Sidecar, not left uncounted.** A review found
4141    // that recording only the rust arm let `oauth_revoke` report a clean success
4142    // while every sidecar revocation failed — and for anyone who logged in before
4143    // the cutover, the sidecar store is the ONLY one that held tokens, so the
4144    // rust arm correctly returns NoSession and the metric reads all-clear while
4145    // live refresh tokens sit at the PDS.
4146    //
4147    // Same op name, different backend: the backend column is what distinguishes
4148    // them, so "no revocation failures" means checking both rows, not one.
4149    let sidecar_started = std::time::Instant::now();
4150    let sidecar_ok = match state.sidecar.revoke_session(did).await {
4151        Ok(res) => {
4152            info!(%did, revoked = res.revoked, "sidecar session revoked");
4153            true
4154        }
4155        Err(err) => {
4156            warn!(%did, %err, "sidecar revoke failed; continuing");
4157            false
4158        }
4159    };
4160    state.metrics.record(
4161        crate::metrics::Backend::Sidecar,
4162        "oauth_revoke",
4163        sidecar_started.elapsed().as_micros() as u64,
4164        sidecar_ok,
4165    );
4166
4167    if let Some(runtime) = state.oauth.as_deref() {
4168        let revoke_started = std::time::Instant::now();
4169        let outcome = crate::oauth::revoke::sign_out_discovering(
4170            runtime,
4171            &state.http,
4172            &state.db,
4173            did,
4174            crate::store::now_unix(),
4175        )
4176        .await;
4177        // **Counted, because a warn! nobody reads is not observability.** Until
4178        // this existed, a revocation failure left exactly one trace: a log line.
4179        // "No revocation failures this week" was therefore a statement about
4180        // nobody having looked, which is not the same claim.
4181        //
4182        // NoSession counts as a SUCCESS, deliberately. Logout is idempotent —
4183        // there being nothing to revoke is the correct outcome, not a failure,
4184        // and counting it as an error would make the metric noisy in exactly
4185        // the case that is fine. Only `Failed` means the PDS still holds live
4186        // tokens we asked it to drop.
4187        let revoke_ok = !matches!(outcome, crate::oauth::revoke::Revocation::Failed(_));
4188        state.metrics.record(
4189            crate::metrics::Backend::Rust,
4190            "oauth_revoke",
4191            revoke_started.elapsed().as_micros() as u64,
4192            revoke_ok,
4193        );
4194        match outcome {
4195            crate::oauth::revoke::Revocation::Revoked => {
4196                info!(%did, "rust OAuth session revoked at the PDS")
4197            }
4198            crate::oauth::revoke::Revocation::NoSession => {}
4199            crate::oauth::revoke::Revocation::Failed(reason) => {
4200                warn!(%did, %reason, "rust OAuth revoke failed; the local session is gone regardless")
4201            }
4202        }
4203    }
4204}
4205
4206/// `POST /logout` — end the session everywhere, not just in this browser.
4207///
4208/// Clearing the cookie only stops *this* device from presenting the session;
4209/// the sidecar still holds live OAuth tokens for the DID. So logout now also
4210/// calls the sidecar `POST /internal/revoke {did}`, which revokes the refresh +
4211/// access tokens at the PDS and drops the sidecar's session rows. The local
4212/// registry entry is dropped and the cookie cleared regardless of whether the
4213/// revoke round-trip succeeds (best-effort — a network blip must not trap the
4214/// user in a half-logged-out state).
4215async fn logout(State(state): State<AppState>, headers: HeaderMap) -> Response {
4216    if let Some(user) = current_session(&state, &headers).await {
4217        // Only a real cookie session (`sid` present) has sidecar-held tokens to
4218        // revoke; the dev-DID fallback never handshook the sidecar.
4219        if let Some(sid) = user.sid {
4220            state.sessions.remove(&sid);
4221            // BEFORE the revoke: afterwards there is no session to send it with.
4222            flush_before_revoke(&state, &user.did).await;
4223            revoke_everywhere(&state, &user.did).await;
4224        }
4225    }
4226    let mut resp = Redirect::to("/login").into_response();
4227    set_cookie(
4228        &mut resp,
4229        &format!("{SESSION_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
4230    );
4231    resp
4232}
4233
4234/// Form body for `POST /account/delete` — the confirm-gate. The user must type
4235/// `DELETE` into this field for the purge to run.
4236#[derive(Debug, Deserialize)]
4237struct DeleteAccountForm {
4238    #[serde(default)]
4239    confirm: String,
4240}
4241
4242/// The literal a user must type to confirm the destructive delete.
4243const DELETE_CONFIRM_PHRASE: &str = "DELETE";
4244
4245/// `POST /account/delete` (authed) — the "delete my data" endpoint.
4246///
4247/// Confirm-gated: the form must carry `confirm=DELETE` or we bounce back to
4248/// `/manage` with an explanatory flash and touch nothing. On confirmation it:
4249///   1. purges **every** local row owned by the caller DID (`entry_state`,
4250///      `read_cursor`, `sub_ref`, `beta_access` seat, and any invite codes the
4251///      DID created) via [`store::purge_did_data`], then
4252///   2. calls the sidecar `POST /internal/revoke {did}` so the OAuth tokens are
4253///      revoked at the PDS and the sidecar's session rows are dropped, then
4254///   3. drops the in-memory session and clears the cookie, signing the user out.
4255///
4256/// The subscription/folder/saved *records* in the user's own PDS are
4257/// intentionally left alone — they are the user's data on their own server; the
4258/// `/about` copy and this page's UI both say so, and export stays available.
4259async fn account_delete(
4260    State(state): State<AppState>,
4261    headers: HeaderMap,
4262    Form(form): Form<DeleteAccountForm>,
4263) -> Result<Response, WebError> {
4264    let user = match current_session(&state, &headers).await {
4265        Some(u) => u,
4266        None => return Ok(Redirect::to("/login").into_response()),
4267    };
4268    let did = user.did.clone();
4269
4270    // Confirm-gate: require the exact typed phrase before doing anything.
4271    if form.confirm.trim() != DELETE_CONFIRM_PHRASE {
4272        return Ok(Redirect::to(&format!(
4273            "/manage?flash={}",
4274            qenc("Type DELETE to confirm — nothing was deleted.")
4275        ))
4276        .into_response());
4277    }
4278
4279    // 1. Purge every local row this DID owns (single transaction).
4280    let counts = store::purge_did_data(&state.db, &did).await?;
4281    info!(
4282        %did,
4283        total = counts.total(),
4284        entry_state = counts.entry_state,
4285        read_cursor = counts.read_cursor,
4286        sub_ref = counts.sub_ref,
4287        beta_access = counts.beta_access,
4288        invite_codes = counts.invite_codes,
4289        "account/delete: local rows purged"
4290    );
4291
4292    // 2. Revoke the OAuth session at the sidecar/PDS (best-effort — the local
4293    //    rows are already gone; a network blip must not block the sign-out).
4294    revoke_everywhere(&state, &did).await;
4295
4296    // 3. Drop the in-memory session and clear the cookie: sign the user out.
4297    if let Some(sid) = user.sid {
4298        state.sessions.remove(&sid);
4299    }
4300    let mut resp = Redirect::to(&format!(
4301        "/login?flash={}",
4302        qenc("Your data was deleted and you've been signed out. Thanks for trying FeatherReader.")
4303    ))
4304    .into_response();
4305    set_cookie(
4306        &mut resp,
4307        &format!("{SESSION_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
4308    );
4309    Ok(resp)
4310}
4311
4312/// Re-render the login form with an error banner.
4313fn login_error(msg: &str) -> Response {
4314    render(&LoginTemplate {
4315        repo_url: REPO_URL,
4316        error: msg.to_string(),
4317        flash: String::new(),
4318    })
4319}
4320
4321// ---------------------------------------------------------------------------
4322// Closed-beta invite gate (self-serve redeem + admin mint)
4323// ---------------------------------------------------------------------------
4324
4325/// Form body for `POST /beta/redeem`.
4326#[derive(Debug, Deserialize)]
4327struct RedeemForm {
4328    code: String,
4329}
4330
4331/// `GET /beta/redeem` — render the invite-redeem page. If the seat cap is
4332/// already full we render the "capacity full" variant (no form).
4333async fn beta_redeem_form(State(state): State<AppState>) -> Response {
4334    let full = store::count_beta_access(&state.db)
4335        .await
4336        .map(|n| n >= state.config.beta_cap)
4337        .unwrap_or(false);
4338    render(&BetaRedeemTemplate {
4339        repo_url: REPO_URL,
4340        error: String::new(),
4341        capacity_full: full,
4342    })
4343}
4344
4345/// `POST /beta/redeem` — the **pre-handshake** reservation.
4346///
4347/// Validates the pasted code is *redeemable right now* (exists, active,
4348/// unexpired, and a seat is free) WITHOUT consuming it or binding a DID — the
4349/// visitor has no DID yet. On success it sets a short-lived signed invite cookie
4350/// reserving intent to redeem this code, then sends the visitor to `/login`. The
4351/// OAuth callback later binds the verified DID and atomically consumes the code
4352/// (`store::redeem_code`). This ordering means a non-invited visitor can never
4353/// start OAuth (and burn a sidecar handshake).
4354async fn beta_redeem_submit(
4355    State(state): State<AppState>,
4356    Form(form): Form<RedeemForm>,
4357) -> Response {
4358    let code = form.code.trim().to_uppercase();
4359    if code.is_empty() {
4360        return render(&BetaRedeemTemplate {
4361            repo_url: REPO_URL,
4362            error: "Enter your invite code.".to_string(),
4363            capacity_full: false,
4364        });
4365    }
4366
4367    match preflight_code(&state, &code).await {
4368        Ok(()) => {
4369            let cookie = sign_invite(&code, &state.config.cookie_secret);
4370            let mut resp = Redirect::to("/login").into_response();
4371            set_cookie(&mut resp, &cookie);
4372            info!("invite code preflight OK; reserving intent + redirecting to /login");
4373            resp
4374        }
4375        Err(policy) => {
4376            warn!(?policy, "invite code preflight rejected");
4377            redeem_bounce(&policy)
4378        }
4379    }
4380}
4381
4382/// Read-only preflight of an invite code for the pre-handshake reservation:
4383/// verify it exists, is active, is not past `expires_at`, and that a seat is
4384/// free — mirroring the checks `store::redeem_code` will re-run atomically at
4385/// callback time. Does NOT consume the code or grant a seat. Returns the same
4386/// typed [`store::RedeemError`] variants so the two paths share one message map.
4387async fn preflight_code(state: &AppState, code: &str) -> Result<(), store::RedeemError> {
4388    // Cap check first: a clear "capacity full" beats "code invalid" when both.
4389    // FAIL CLOSED on a count error — an `unwrap_or(0)` would let a DB blip read as
4390    // "0 seats used" and wave the redeem through the preflight. (`redeem_code`
4391    // still backstops the real cap inside its tx, so this is a consistency /
4392    // defence-in-depth fix, not the only guard.) Treat an unverifiable count as
4393    // capacity-full: the redeemer sees "at capacity, try later" rather than a mint
4394    // that might overrun the cap.
4395    let count = match store::count_beta_access(&state.db).await {
4396        Ok(n) => n,
4397        Err(err) => {
4398            warn!(%err, "preflight_code: count_beta_access failed; failing closed");
4399            return Err(store::RedeemError::CapacityFull);
4400        }
4401    };
4402    if count >= state.config.beta_cap {
4403        return Err(store::RedeemError::CapacityFull);
4404    }
4405    // Look up the code's current status + expiry (read-only).
4406    let row = sqlx::query_as::<_, (String, i64)>(
4407        "SELECT status, expires_at FROM invite_codes WHERE code = ?1",
4408    )
4409    .bind(code)
4410    .fetch_optional(&state.db)
4411    .await
4412    .ok()
4413    .flatten();
4414    let (status, expires_at) = match row {
4415        Some(r) => r,
4416        None => return Err(store::RedeemError::NotFound),
4417    };
4418    let now = chrono::Utc::now().timestamp();
4419    match status.as_str() {
4420        "active" if expires_at >= now => Ok(()),
4421        "active" => Err(store::RedeemError::Expired),
4422        "expired" => Err(store::RedeemError::Expired),
4423        // "redeemed" or anything else non-active.
4424        _ => Err(store::RedeemError::AlreadyRedeemed),
4425    }
4426}
4427
4428/// Map a [`store::RedeemError`] to the invite page with the right message. Used
4429/// by both the preflight (`POST /beta/redeem`) and the callback bind path.
4430fn redeem_bounce(policy: &store::RedeemError) -> Response {
4431    use store::RedeemError::*;
4432    let (msg, capacity_full) = match policy {
4433        NotFound => ("That invite code isn't valid.", false),
4434        Expired => ("That invite code has expired.", false),
4435        AlreadyRedeemed => ("That invite code has already been used.", false),
4436        CapacityFull => ("", true),
4437    };
4438    render(&BetaRedeemTemplate {
4439        repo_url: REPO_URL,
4440        error: msg.to_string(),
4441        capacity_full,
4442    })
4443}
4444
4445/// Query for `POST /admin/invites` — how many codes to mint (`?n=`, default 1).
4446#[derive(Debug, Deserialize, Default)]
4447struct MintQuery {
4448    #[serde(default)]
4449    n: Option<u32>,
4450}
4451
4452/// `POST /admin/invites?n=N` — mint N invite codes.
4453///
4454/// `GET /oauth/client-metadata.json` — the client's published identity.
4455///
4456/// **This URL IS the `client_id`.** The PDS fetches it during every login and
4457/// caches it against every existing grant, so it must keep answering at exactly
4458/// this path across the cutover — the sidecar serves the same document at the
4459/// same URL today, proxied by the edge.
4460///
4461/// Served whatever backend is live: a request that arrives here is from a PDS
4462/// resolving our identity, and it has no idea which of our two implementations
4463/// is currently answering repo calls.
4464async fn oauth_client_metadata(State(state): State<AppState>) -> Response {
4465    let Some(runtime) = state.oauth.as_deref() else {
4466        // The sidecar is serving this path in front of us, or nothing is.
4467        return (StatusCode::NOT_FOUND, "no client metadata\n").into_response();
4468    };
4469    axum::Json(crate::oauth::metadata::client_metadata(&runtime.client)).into_response()
4470}
4471
4472/// `GET /oauth/jwks.json` — the client's public signing key.
4473///
4474/// Production only. The localhost dev client is a PUBLIC client: it registers no
4475/// key and signs no assertions, so publishing a JWKS there would advertise a
4476/// credential that is never used — and would make a dev deployment look like a
4477/// confidential client to anyone reading it.
4478async fn oauth_jwks(State(state): State<AppState>) -> Response {
4479    let Some(runtime) = state.oauth.as_deref() else {
4480        return (StatusCode::NOT_FOUND, "no jwks\n").into_response();
4481    };
4482    match runtime.client_key.as_ref() {
4483        Some(key) => match key.jwks_document() {
4484            Ok(doc) => axum::Json(doc).into_response(),
4485            Err(err) => {
4486                warn!(%err, "could not render the client JWKS");
4487                (StatusCode::INTERNAL_SERVER_ERROR, "jwks unavailable\n").into_response()
4488            }
4489        },
4490        None => (StatusCode::NOT_FOUND, "this client publishes no jwks\n").into_response(),
4491    }
4492}
4493
4494/// How many failing feeds `/admin/metrics` will name. One response, so bounded.
4495const ADMIN_FAILING_FEED_LIMIT: i64 = 200;
4496
4497/// `GET /admin/metrics` — repo-op latency for both backends, as plain text.
4498///
4499/// Admin-gated on the same rule as the invite minter: the table names every
4500/// operation the reader performs and how often each fails, which is an
4501/// operational picture rather than public information.
4502///
4503/// Text, not JSON or HTML: it is read by a person deciding whether the cutover
4504/// is safe, and the comparison is two rows side by side.
4505async fn admin_metrics(State(state): State<AppState>, headers: HeaderMap) -> Response {
4506    let did = match current_did(&state, &headers).await {
4507        Some(d) => d,
4508        None => return (StatusCode::UNAUTHORIZED, "sign in first\n").into_response(),
4509    };
4510    if !state.config.admin_seed_dids().iter().any(|d| d == &did) {
4511        warn!(%did, "admin metrics denied: not an admin-seed DID");
4512        return (StatusCode::FORBIDDEN, "not an admin\n").into_response();
4513    }
4514
4515    // Flush first, so the table includes this process's traffic up to now.
4516    // Then read the PERSISTED rows, which is the only place both backends can
4517    // appear at once -- a flip is a restart, and in-process memory only ever
4518    // holds the backend currently running.
4519    if let Err(err) =
4520        crate::metrics::flush(&state.metrics, &state.db, crate::store::now_unix()).await
4521    {
4522        warn!(%err, "could not flush repo timings before rendering");
4523    }
4524    let rows = match crate::metrics::persisted_rows(&state.db).await {
4525        Ok(rows) => rows,
4526        Err(err) => {
4527            warn!(%err, "could not read persisted repo timings");
4528            return (StatusCode::INTERNAL_SERVER_ERROR, "metrics unavailable\n").into_response();
4529        }
4530    };
4531
4532    // The live backend is named at the top: a table of two populated rows is
4533    // ambiguous about which one is currently serving users.
4534    // Parked read-state, alongside the timings. The flusher no longer logs
4535    // these every round (#117), so without a number here the state would be
4536    // silent — which is the failure the noisy loop at least did not have.
4537    let parked = match crate::store::parked_readstate_dids(&state.db).await {
4538        Ok(n) => n.to_string(),
4539        Err(err) => {
4540            warn!(%err, "could not count parked read-state DIDs");
4541            "unknown".to_string()
4542        }
4543    };
4544    // **The half the public histogram cannot carry.** `/stats` reports counts by
4545    // cause and nothing else, deliberately — but `fetch` covers DNS failure,
4546    // timeout, SSRF refusal AND this reader's own bugs, so the count alone
4547    // cannot separate "the publishers are gone" from "we are broken". #159 was
4548    // the latter and took a production investigation to establish. Named feeds
4549    // and their error text belong here, behind ALLOWED_DIDS.
4550    let failing = match crate::store::failing_feeds(&state.db, ADMIN_FAILING_FEED_LIMIT).await {
4551        Ok(f) => f,
4552        Err(err) => {
4553            warn!(%err, "could not list failing feeds");
4554            Vec::new()
4555        }
4556    };
4557    let mut failing_block = String::new();
4558    if !failing.is_empty() {
4559        failing_block.push_str("\nfailing feeds (worst first)\n");
4560        for f in &failing {
4561            failing_block.push_str(&format!(
4562                "  {:>4}x  {:<8}  {}\n          {}\n",
4563                f.consecutive_errors,
4564                f.kind.as_deref().unwrap_or("unknown"),
4565                f.url,
4566                f.detail.as_deref().unwrap_or("(no detail recorded)"),
4567            ));
4568        }
4569    }
4570
4571    // **Capacity that no other page can show.** The global ceiling counts every
4572    // row (`store::count_feeds`), but `/stats` measures the poller and excludes
4573    // unpollable ones — so an instance can be at its cap with every public
4574    // number saying otherwise. A review found exactly that gap.
4575    let unpollable = match crate::store::unpollable_feeds(&state.db).await {
4576        Ok(n) => n,
4577        Err(err) => {
4578            warn!(%err, "could not count unpollable feeds");
4579            -1
4580        }
4581    };
4582    let cached = crate::store::count_feeds(&state.db).await.unwrap_or(-1);
4583
4584    let body = format!(
4585        "live backend: {}\nparked read-state DIDs: {}\n\
4586         feeds cached: {} (ceiling {}), of which unpollable: {}\n\n{}{}",
4587        state.config.repo_backend.as_str(),
4588        parked,
4589        cached,
4590        state.config.max_feeds_global,
4591        unpollable,
4592        crate::metrics::render(&rows),
4593        failing_block,
4594    );
4595    (StatusCode::OK, body).into_response()
4596}
4597
4598/// Authorized ONLY for a live session whose DID is in the `ALLOWED_DIDS` admin
4599/// seed (`config.admin_seed_dids`). Returns the freshly-minted codes as
4600/// newline-separated `text/plain`. Deliberately minimal (no HTML UI).
4601async fn admin_mint_invites(
4602    State(state): State<AppState>,
4603    headers: HeaderMap,
4604    Query(q): Query<MintQuery>,
4605) -> Response {
4606    // Require a real, current session (not just a DID string) whose DID is an
4607    // admin-seed DID. `current_did` already re-checks the beta gate.
4608    let did = match current_did(&state, &headers).await {
4609        Some(d) => d,
4610        None => return (StatusCode::UNAUTHORIZED, "sign in first\n").into_response(),
4611    };
4612    if !state.config.admin_seed_dids().iter().any(|d| d == &did) {
4613        warn!(%did, "admin mint denied: not an admin-seed DID");
4614        return (StatusCode::FORBIDDEN, "not an admin\n").into_response();
4615    }
4616
4617    let n = q.n.unwrap_or(1).clamp(1, 100);
4618    let mut codes = Vec::with_capacity(n as usize);
4619    for _ in 0..n {
4620        match store::mint_code(&state.db, &did, INVITE_TTL_SECS).await {
4621            Ok(code) => codes.push(code),
4622            Err(err) => {
4623                warn!(%err, %did, "admin mint_code failed");
4624                return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
4625            }
4626        }
4627    }
4628    info!(%did, count = codes.len(), "admin minted invite codes");
4629    let mut body = codes.join("\n");
4630    body.push('\n');
4631    (StatusCode::OK, body).into_response()
4632}
4633
4634// ---------------------------------------------------------------------------
4635// Bot claim link: /claim?t=<token>  +  POST /bot/claims (shared-secret mint)
4636// ---------------------------------------------------------------------------
4637
4638/// Query for `GET /claim`.
4639#[derive(Debug, Deserialize)]
4640struct ClaimQuery {
4641    /// The opaque claim token from the bot's public follow-back skeet.
4642    t: Option<String>,
4643}
4644
4645/// `GET /claim?t=<token>` — redeem a bot-issued claim link.
4646///
4647/// The follow→invite bot posts a public skeet mentioning a new follower with a
4648/// link here. The token wraps a pre-minted invite code (never the raw code — see
4649/// [`sign_claim_token`]). On a valid, still-redeemable token this behaves exactly
4650/// like a successful `POST /beta/redeem`: it sets the reserving `fr_invite`
4651/// cookie and sends the visitor to `/login`, so they flow through OAuth and the
4652/// callback atomically consumes the code (`store::redeem_code`) — the same
4653/// machinery as a pasted code. On any failure it bounces to the invite page with
4654/// the matching message.
4655///
4656/// Single-use / grabbability: a token in a public URL is grabbable. The code it
4657/// wraps is single-use (redeem flips `active→redeemed`), and `preflight_code`
4658/// here rejects an already-used / expired / capacity-full code before reserving,
4659/// so a replayed link past the first successful claim is refused. The residual
4660/// window is the same as any pasted invite code: whoever completes OAuth *first*
4661/// with a live reservation wins the seat. The per-IP rate limit on `/claim`
4662/// blunts brute-force enumeration.
4663async fn claim(State(state): State<AppState>, Query(q): Query<ClaimQuery>) -> Response {
4664    let token = match q.t {
4665        Some(t) if !t.is_empty() => t,
4666        _ => {
4667            warn!("claim link with no token");
4668            return redeem_bounce(&store::RedeemError::NotFound);
4669        }
4670    };
4671
4672    // Unwrap the token → the invite code it reserves. A tampered/forged token
4673    // yields nothing → treat as an invalid code (don't leak whether it parsed).
4674    let code = match claim_token_code(&token, &state.config.cookie_secret) {
4675        Some(c) => c,
4676        None => {
4677            warn!("claim token invalid (bad signature / malformed)");
4678            return redeem_bounce(&store::RedeemError::NotFound);
4679        }
4680    };
4681
4682    // Re-run the same preflight as the pasted-code path: exists, active,
4683    // unexpired, seat free. This is what makes a replayed link past first-claim
4684    // (or past cap) fail cleanly.
4685    match preflight_code(&state, &code).await {
4686        Ok(()) => {
4687            let cookie = sign_invite(&code, &state.config.cookie_secret);
4688            let mut resp = Redirect::to("/login").into_response();
4689            set_cookie(&mut resp, &cookie);
4690            info!("claim token preflight OK; reserving intent + redirecting to /login");
4691            resp
4692        }
4693        Err(policy) => {
4694            warn!(?policy, "claim token preflight rejected");
4695            redeem_bounce(&policy)
4696        }
4697    }
4698}
4699
4700/// The JSON body `POST /bot/claims` accepts — the follower the claim is FOR.
4701///
4702/// Passing the follower DID makes the APP the authoritative deduper: the app can
4703/// short-circuit a DID that already holds a seat, and return the SAME code for a
4704/// DID that already has an outstanding claim — so a bot-host state loss cannot
4705/// re-mint or re-post per follower. Handle is advisory (logs only).
4706#[derive(Debug, Default, Deserialize)]
4707struct BotClaimRequest {
4708    /// The follower's DID (the idempotency key). Optional for backward-compat: an
4709    /// omitted DID falls back to the old un-keyed mint (no server-side dedupe).
4710    #[serde(default)]
4711    did: Option<String>,
4712    /// The follower's handle (advisory; recorded for operator logs only).
4713    #[serde(default)]
4714    #[allow(dead_code)]
4715    handle: Option<String>,
4716}
4717
4718/// The JSON body `POST /bot/claims` returns on success.
4719#[derive(Debug, serde::Serialize)]
4720struct BotClaimResponse {
4721    /// Server-side dedupe outcome, so the bot knows whether to post:
4722    /// `"minted"` (a fresh code — post the claim link), `"existing"` (this DID
4723    /// already had an outstanding claim; the SAME code/token/url is returned, so an
4724    /// idempotent re-post is safe), or `"already_seated"` (this DID already holds
4725    /// beta access; code/token/url are empty and the bot should post NOTHING).
4726    status: &'static str,
4727    /// The bare invite code (`FEATHER-…`) — for the bot's own logs/idempotency
4728    /// store. NEVER post this publicly; post the `url` instead. Empty when
4729    /// `already_seated`.
4730    code: String,
4731    /// The opaque claim token (the code wrapped + signed). Empty when
4732    /// `already_seated`.
4733    token: String,
4734    /// The full claim URL to put in the public skeet: `${public_url}/claim?t=…`.
4735    /// Empty when `already_seated`.
4736    url: String,
4737}
4738
4739/// `POST /bot/claims` — headless, shared-secret mint of a claim link.
4740///
4741/// Auth is a bearer shared secret in the `X-Bot-Secret` header (== the Fly secret
4742/// `FEATHERREADER_BOT_SECRET`), NOT an OAuth cookie — so the homelab-hosted bot
4743/// can call it. When `FEATHERREADER_BOT_SECRET` is unset the endpoint is DISABLED
4744/// (503), so a bare/dev instance never exposes an unauthenticated mint.
4745///
4746/// Server-side DID idempotency (the authoritative dedupe backstop): the request
4747/// body carries the follower `did`. The app — not the bot's local SQLite — is the
4748/// source of truth, so a bot-host state loss cannot re-mint or re-post per
4749/// follower:
4750///   * DID already holds beta access → `200 {status:"already_seated"}` (empty
4751///     code/url; the bot marks it handled and posts NOTHING);
4752///   * DID already has an outstanding active claim → `200 {status:"existing"}`
4753///     returning the SAME code/token/url (idempotent — never a second mint);
4754///   * otherwise mint a fresh code recorded FOR that DID → `200 {status:"minted"}`.
4755///
4756/// Cap accounting: the bot must not promise more claims than seats remain, so
4757/// this refuses with `409 Conflict {"error":"full"}` when
4758/// `beta_access + outstanding active codes >= FEATHERREADER_BETA_CAP`. (The
4759/// redeem-time cap in `store::redeem_code` is still the hard backstop.) The count
4760/// queries FAIL CLOSED: a DB error propagates as `500` rather than reading 0 and
4761/// minting past the cap.
4762///
4763/// On a fresh mint it uses the generous claim TTL (`FEATHERREADER_CLAIM_TTL_SECS`,
4764/// default 14d — the admin browser flow's 30-min TTL would expire before the
4765/// follower taps an async-delivered link).
4766async fn bot_mint_claim(
4767    State(state): State<AppState>,
4768    headers: HeaderMap,
4769    body: axum::body::Bytes,
4770) -> Response {
4771    // 1. The endpoint is OFF unless a bot secret is configured.
4772    let bot_secret = match state.config.bot_secret.as_deref() {
4773        Some(s) => s,
4774        None => {
4775            warn!(
4776                "POST /bot/claims called but FEATHERREADER_BOT_SECRET is unset (endpoint disabled)"
4777            );
4778            return (
4779                StatusCode::SERVICE_UNAVAILABLE,
4780                "bot mint endpoint disabled (FEATHERREADER_BOT_SECRET unset)\n",
4781            )
4782                .into_response();
4783        }
4784    };
4785
4786    // 2. Constant-time bearer check on the X-Bot-Secret header.
4787    let presented = headers
4788        .get("x-bot-secret")
4789        .and_then(|v| v.to_str().ok())
4790        .unwrap_or("");
4791    if !bot_secret_matches(presented, bot_secret) {
4792        warn!("POST /bot/claims rejected: bad or missing X-Bot-Secret");
4793        return (StatusCode::UNAUTHORIZED, "bad bot secret\n").into_response();
4794    }
4795
4796    // 2b. Parse the (optional) JSON body → the follower DID/handle. An empty body
4797    // (legacy caller) parses to an all-None request; a malformed body is a 400.
4798    let req: BotClaimRequest = if body.is_empty() {
4799        BotClaimRequest::default()
4800    } else {
4801        match serde_json::from_slice(&body) {
4802            Ok(r) => r,
4803            Err(err) => {
4804                warn!(%err, "POST /bot/claims: bad JSON body");
4805                return (StatusCode::BAD_REQUEST, "bad json body\n").into_response();
4806            }
4807        }
4808    };
4809    let follower_did = req.did.as_deref().filter(|d| !d.is_empty());
4810
4811    // 3. Server-side DID idempotency (only when a DID was supplied):
4812    if let Some(did) = follower_did {
4813        // 3a. Already seated → tell the bot to post nothing.
4814        match store::has_beta_access(&state.db, did).await {
4815            Ok(true) => {
4816                info!("bot mint: DID already holds beta access; already_seated");
4817                return bot_claim_json(BotClaimResponse {
4818                    status: "already_seated",
4819                    code: String::new(),
4820                    token: String::new(),
4821                    url: String::new(),
4822                });
4823            }
4824            Ok(false) => {}
4825            Err(err) => {
4826                // Fail closed: a DB error must not fall through to a fresh mint.
4827                warn!(%err, "bot mint: has_beta_access failed");
4828                return (StatusCode::INTERNAL_SERVER_ERROR, "lookup failed\n").into_response();
4829            }
4830        }
4831        // 3b. Outstanding active claim for this DID → return the SAME code (no
4832        // second mint). This is what survives a bot-host state loss.
4833        match store::find_active_code_for_did(&state.db, did).await {
4834            Ok(Some(code)) => {
4835                info!("bot mint: existing outstanding claim for DID; returning same code");
4836                let token = sign_claim_token(&code, &state.config.cookie_secret);
4837                let url = format!("{}/claim?t={}", state.config.public_url, qenc(&token));
4838                return bot_claim_json(BotClaimResponse {
4839                    status: "existing",
4840                    code,
4841                    token,
4842                    url,
4843                });
4844            }
4845            Ok(None) => {}
4846            Err(err) => {
4847                warn!(%err, "bot mint: find_active_code_for_did failed");
4848                return (StatusCode::INTERNAL_SERVER_ERROR, "lookup failed\n").into_response();
4849            }
4850        }
4851    }
4852
4853    // 4. Cap accounting: seats already granted + outstanding unredeemed codes.
4854    //    FAIL CLOSED — a count error is a 500, not a silent mint past the cap.
4855    let granted = match store::count_beta_access(&state.db).await {
4856        Ok(n) => n,
4857        Err(err) => {
4858            warn!(%err, "bot mint: count_beta_access failed; failing closed");
4859            return (StatusCode::INTERNAL_SERVER_ERROR, "count failed\n").into_response();
4860        }
4861    };
4862    let outstanding = match store::count_active_codes(&state.db).await {
4863        Ok(n) => n,
4864        Err(err) => {
4865            warn!(%err, "bot mint: count_active_codes failed; failing closed");
4866            return (StatusCode::INTERNAL_SERVER_ERROR, "count failed\n").into_response();
4867        }
4868    };
4869    if granted + outstanding >= state.config.beta_cap {
4870        info!(
4871            granted,
4872            outstanding,
4873            cap = state.config.beta_cap,
4874            "bot mint refused: at capacity"
4875        );
4876        return (
4877            StatusCode::CONFLICT,
4878            [(header::CONTENT_TYPE, "application/json")],
4879            "{\"error\":\"full\"}\n",
4880        )
4881            .into_response();
4882    }
4883
4884    // 5. Mint with the generous claim TTL, recording the follower DID (when given)
4885    //    so a re-request for the same DID returns THIS code idempotently.
4886    let bot_did = state
4887        .config
4888        .admin_seed_dids()
4889        .first()
4890        .cloned()
4891        .unwrap_or_else(|| "did:bot:featherreader".to_string());
4892    let minted = match follower_did {
4893        Some(did) => {
4894            store::mint_code_for_did(&state.db, &bot_did, state.config.claim_ttl_secs, did).await
4895        }
4896        None => store::mint_code(&state.db, &bot_did, state.config.claim_ttl_secs).await,
4897    };
4898    let code = match minted {
4899        Ok(c) => c,
4900        // S4: the dedupe check (3b) and this mint are separate statements, so two
4901        // concurrent requests for one DID can both fall through 3b's `Ok(None)`.
4902        // The partial unique index `idx_invite_codes_intended_active` makes the
4903        // loser's INSERT fail (only one active row per intended DID), which
4904        // surfaces here as a conflict. Recover by returning the winner's existing
4905        // code (same shape as the 3b idempotent path) instead of a 500.
4906        Err(err) if follower_did.is_some() && store::is_intended_active_conflict(&err) => {
4907            match store::find_active_code_for_did(&state.db, follower_did.unwrap()).await {
4908                Ok(Some(code)) => {
4909                    info!("bot mint: lost the mint race; returning the concurrently-minted code");
4910                    let token = sign_claim_token(&code, &state.config.cookie_secret);
4911                    let url = format!("{}/claim?t={}", state.config.public_url, qenc(&token));
4912                    return bot_claim_json(BotClaimResponse {
4913                        status: "existing",
4914                        code,
4915                        token,
4916                        url,
4917                    });
4918                }
4919                // The winner's row vanished between the conflict and this lookup
4920                // (redeemed/expired/purged in the gap) — nothing to hand back.
4921                // Fail closed rather than silently mint past the just-hit guard.
4922                Ok(None) => {
4923                    warn!("bot mint: conflict but no active code found on recovery");
4924                    return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
4925                }
4926                Err(err) => {
4927                    warn!(%err, "bot mint: recovery lookup after conflict failed");
4928                    return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
4929                }
4930            }
4931        }
4932        Err(err) => {
4933            warn!(%err, "bot mint_code failed");
4934            return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
4935        }
4936    };
4937    let token = sign_claim_token(&code, &state.config.cookie_secret);
4938    let url = format!("{}/claim?t={}", state.config.public_url, qenc(&token));
4939    info!("bot minted a claim code + token");
4940
4941    bot_claim_json(BotClaimResponse {
4942        status: "minted",
4943        code,
4944        token,
4945        url,
4946    })
4947}
4948
4949/// Serialize a [`BotClaimResponse`] to a `200 application/json` response (or a
4950/// `500` if serialization somehow fails).
4951fn bot_claim_json(resp: BotClaimResponse) -> Response {
4952    match serde_json::to_string(&resp) {
4953        Ok(body) => (
4954            StatusCode::OK,
4955            [(header::CONTENT_TYPE, "application/json")],
4956            body,
4957        )
4958            .into_response(),
4959        Err(err) => {
4960            warn!(%err, "serializing bot claim response failed");
4961            (StatusCode::INTERNAL_SERVER_ERROR, "serialize failed\n").into_response()
4962        }
4963    }
4964}
4965
4966/// Constant-time equality for the bot bearer secret (avoid a timing side-channel
4967/// on the shared secret). Delegates to the same `cookie::constant_time_eq` used
4968/// by the HMAC checks so there is one comparator to audit; a length mismatch
4969/// short-circuits to `false`, which is fine — the secret length isn't sensitive.
4970fn bot_secret_matches(presented: &str, expected: &str) -> bool {
4971    cookie::constant_time_eq(presented.as_bytes(), expected.as_bytes())
4972}
4973
4974// ---------------------------------------------------------------------------
4975// Signed, short-lived invite cookie (reuses the session-cookie HMAC helper)
4976// ---------------------------------------------------------------------------
4977
4978/// Sign the reserved invite `code` into a short-lived `Set-Cookie` value. Reuses
4979/// the same HMAC-SHA256 helper as the session cookie; the payload is the code
4980/// itself (base64url) rather than an opaque sid, since the code IS the reserved
4981/// intent the callback consumes.
4982fn sign_invite(code: &str, secret: &str) -> String {
4983    cookie::sign_value(INVITE_COOKIE, code, secret, INVITE_TTL_SECS)
4984}
4985
4986/// Verify + read the reserved invite code out of the request's invite cookie
4987/// (`None` if absent, tampered, or forged). No expiry is enforced here beyond
4988/// the cookie's own `Max-Age`; the atomic `redeem_code` at the callback is the
4989/// authority on the code's live status.
4990fn invite_cookie_code(headers: &HeaderMap, secret: &str) -> Option<String> {
4991    cookie::verify_value(headers, INVITE_COOKIE, secret)
4992}
4993
4994/// Domain-separation label for the claim TOKEN's HMAC (distinct from the
4995/// `fr_invite`/`fr_session` cookie names), so a token can never be replayed as a
4996/// cookie value and vice-versa.
4997const CLAIM_TOKEN_LABEL: &str = "claim-token";
4998
4999/// Sign an invite `code` into a URL-safe claim TOKEN: `b64url(code).<sig>`
5000/// (HMAC-SHA256 over `"claim-token" || 0x00 || code`).
5001///
5002/// NOTE — the token is NOT confidential: the `b64url(code)` half is trivially
5003/// decodable by anyone, so the raw `FEATHER-…` code is effectively public in the
5004/// claim URL. The token's security is INTEGRITY + SINGLE-USE, not secrecy: the
5005/// `<sig>` HMAC means only this instance can MINT a valid token (a forged/guessed
5006/// code won't verify), the wrapped code is single-use (redeem flips
5007/// `active→redeemed`), and `/claim` is per-IP rate-limited. Wrapping keeps the
5008/// token one self-contained string needing no server-side token table; it does
5009/// NOT hide the code.
5010fn sign_claim_token(code: &str, secret: &str) -> String {
5011    cookie::sign_token(CLAIM_TOKEN_LABEL, code, secret)
5012}
5013
5014/// Verify a claim token and return the invite code it wraps (`None` on a tampered
5015/// / forged / malformed token). The code's live status (active/unexpired/seat
5016/// free) is re-checked by `preflight_code`; this only proves the token was minted
5017/// by this instance.
5018fn claim_token_code(token: &str, secret: &str) -> Option<String> {
5019    cookie::verify_token(CLAIM_TOKEN_LABEL, token, secret)
5020}
5021
5022/// Clear the invite cookie on a response (after a successful bind, or when the
5023/// reservation turned out to be stale).
5024fn clear_invite_cookie(resp: &mut Response) {
5025    set_cookie(
5026        resp,
5027        &format!("{INVITE_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
5028    );
5029}
5030
5031// ---------------------------------------------------------------------------
5032// OPML import + export
5033// ---------------------------------------------------------------------------
5034
5035/// `POST /opml` — import subscriptions from an OPML document.
5036///
5037/// Accepts either a multipart file upload (field `file`) or a pasted textarea
5038/// (field `opml`). The parsed feeds each become a `community.lexicon.rss.folder`
5039/// (for any named folders) + a `community.lexicon.rss.subscription` record in the
5040/// user's PDS via the records layer's bulk-add (`add_subscriptions_bulk`, one
5041/// `applyWrites` round-trip). Feeds are also upserted into the local cache so
5042/// they show immediately; polling is left to the background poller.
5043async fn import_opml(
5044    State(state): State<AppState>,
5045    headers: HeaderMap,
5046    mut multipart: Multipart,
5047) -> Result<Response, WebError> {
5048    let did = match current_did(&state, &headers).await {
5049        Some(d) => d,
5050        None => return Ok(Redirect::to("/login").into_response()),
5051    };
5052    let pool = &state.db;
5053
5054    // Collect the OPML text from whichever field carried it. Multipart errors
5055    // are mapped to their axum-native response so that an over-cap upload (the
5056    // `DefaultBodyLimit` on this route, see `OPML_BODY_LIMIT`) surfaces as
5057    // `413 Payload Too Large` rather than being swallowed by the blanket
5058    // `WebError` → `500` conversion.
5059    let mut opml_text = String::new();
5060    while let Some(field) = multipart.next_field().await.map_err(multipart_response)? {
5061        let name = field.name().unwrap_or("").to_string();
5062        if name == "opml" || name == "file" {
5063            let bytes = field.bytes().await.map_err(multipart_response)?;
5064            if !bytes.is_empty() {
5065                opml_text = String::from_utf8_lossy(&bytes).into_owned();
5066                if name == "file" {
5067                    break;
5068                }
5069            }
5070        }
5071    }
5072
5073    // A parse FAILURE and an empty-but-valid file are different things, and
5074    // `unwrap_or_default` collapsed them: a malformed export was reported to the
5075    // reader as "No feeds found in that OPML", which sends them looking at their
5076    // old reader for feeds that are right there in the file.
5077    let feeds =
5078        match opml::parse_opml(&opml_text) {
5079            Ok(feeds) => feeds,
5080            Err(err) => {
5081                warn!(%err, %did, "OPML import could not parse the uploaded file");
5082                return Ok(Redirect::to(&format!(
5083                "/?flash={}",
5084                qenc("That file could not be read as OPML. Export it again from your other reader?")
5085            ))
5086                .into_response());
5087            }
5088        };
5089    if feeds.is_empty() {
5090        info!(%did, "OPML import found no feeds");
5091        return Ok(
5092            Redirect::to(&format!("/?flash={}", qenc("No feeds found in that OPML")))
5093                .into_response(),
5094        );
5095    }
5096
5097    // Create any named folders first, mapping folder name → at:// URI so
5098    // subscriptions can reference them.
5099    let now = now_rfc3339();
5100    let mut folder_uris: std::collections::HashMap<String, String> =
5101        std::collections::HashMap::new();
5102    // Reuse existing folders where the name already exists.
5103    if let Ok(existing) = state.repo().list_folders_sorted(&did).await {
5104        for (rkey, folder) in existing {
5105            folder_uris
5106                .entry(folder.name.clone())
5107                .or_insert_with(|| folder_uri(&did, &rkey));
5108        }
5109    }
5110    let mut wanted_folders: Vec<String> = feeds
5111        .iter()
5112        .filter_map(|f| f.folder.clone())
5113        .filter(|n| !n.is_empty())
5114        .collect();
5115    wanted_folders.sort();
5116    wanted_folders.dedup();
5117    for name in wanted_folders {
5118        if folder_uris.contains_key(&name) {
5119            continue;
5120        }
5121        let folder = Folder::new(name.clone(), now.clone());
5122        match state.repo().add_folder(&did, &folder).await {
5123            Ok(rkey) => {
5124                folder_uris.insert(name, folder_uri(&did, &rkey));
5125            }
5126            Err(err) => warn!(%err, %did, "OPML folder create failed"),
5127        }
5128    }
5129
5130    // Build one subscription record per PUBLIC feed + upsert the local cache row.
5131    // Private/paid feeds are SKIPPED entirely (never stored, fetched, or written)
5132    // and reported back to the user — the same public-feeds-only stance as the
5133    // single-add path, so an OPML import can't leak a Substack/Patreon/podcast
5134    // token onto the public network either.
5135    // Per-DID subscription cap: an OPML import must not blow past the cap. Compute
5136    // the remaining headroom (cap − existing) once; public feeds beyond it are
5137    // TRIMMED (not imported) and reported. `<= 0` disables the cap.
5138    let sub_cap = state.config.max_subs_per_did;
5139    let mut headroom: Option<i64> = if sub_cap > 0 {
5140        let existing = store::count_subscriptions_for_did(pool, &did)
5141            .await
5142            .unwrap_or(0);
5143        Some((sub_cap - existing).max(0))
5144    } else {
5145        None
5146    };
5147    let mut trimmed_over_cap: usize = 0;
5148
5149    // Global feeds ceiling: an OPML import must not blow past the shared cache
5150    // ceiling any more than the single-add path may. Seed the remaining global
5151    // headroom (cap − current feeds) once, and only a BRAND-NEW feed URL (one
5152    // not already cached) consumes it. Existing/duplicate URLs add no row and
5153    // are always allowed. Feeds past the ceiling are TRIMMED and reported.
5154    // `<= 0` disables the ceiling.
5155    let feeds_cap = state.config.max_feeds_global;
5156    let mut global_headroom: Option<i64> = if feeds_cap > 0 {
5157        let existing = store::count_feeds(pool).await.unwrap_or(0);
5158        Some((feeds_cap - existing).max(0))
5159    } else {
5160        None
5161    };
5162    let mut trimmed_over_global: usize = 0;
5163
5164    let mut subs = Vec::with_capacity(feeds.len());
5165    let mut skipped_private: Vec<String> = Vec::new();
5166    // Imported into the PDS but not cached locally, so not pollable until the
5167    // next import touches them. Counted rather than only logged — see below.
5168    let mut uncached: usize = 0;
5169    // Entries this instance cannot store at all (an `at://` publication with
5170    // the flag off, an unsupported scheme). Counted, because the `continue`
5171    // below used to increment nothing while the privacy branch beside it
5172    // produced a label — so an OPML from a standard.site-enabled instance
5173    // imported "successfully" with entries missing and no reason given.
5174    let mut skipped_unsupported: usize = 0;
5175    for f in &feeds {
5176        // `xmlUrl` is whatever the uploaded file says, and nothing on this path
5177        // ever parsed it — the single-add path can't reach here because
5178        // `resolve_feed_url` must parse AND successfully fetch first. So
5179        // `javascript:alert(1)` and `file:///etc/passwd` were both accepted,
5180        // cached, and published as records to the user's PUBLIC repo. Note that
5181        // `classify_feed_privacy` does not catch these: both parse cleanly, and
5182        // it returns `Public` for anything unparseable by design.
5183        if !feed::is_storable_feed_url(&f.feed_url, state.config.standard_site) {
5184            info!(
5185                %did,
5186                "skipped an OPML entry whose xmlUrl is not a storable feed URL"
5187            );
5188            skipped_unsupported += 1;
5189            continue;
5190        }
5191        if let feed::FeedPrivacy::Private(reason) = feed::classify_feed_privacy(&f.feed_url) {
5192            info!(feed = %f.feed_url, %reason, %did, "skipped private/paid feed on OPML import (not stored)");
5193            // Report by title where we have one, else the (public-safe) host.
5194            let label = f
5195                .title
5196                .clone()
5197                .filter(|t| !t.trim().is_empty())
5198                .unwrap_or_else(|| private_feed_label(&f.feed_url));
5199            skipped_private.push(label);
5200            continue;
5201        }
5202
5203        // Over-cap: stop importing once headroom is exhausted (count the rest so
5204        // we can tell the user how many were dropped).
5205        if let Some(h) = headroom.as_mut() {
5206            if *h <= 0 {
5207                trimmed_over_cap += 1;
5208                continue;
5209            }
5210        }
5211
5212        // Global ceiling: a brand-new feed URL consumes global headroom. Once
5213        // it's exhausted, refuse to cache further NEW feeds (existing URLs are
5214        // free — they add no row). Checked before decrementing the per-DID
5215        // headroom so a dropped feed doesn't burn the caller's own quota.
5216        let is_new = match store::get_feed_by_url(pool, &f.feed_url).await {
5217            Ok(existing) => existing.is_none(),
5218            // On a lookup error, treat as existing (don't consume global
5219            // headroom) but still allow the upsert to proceed.
5220            Err(err) => {
5221                warn!(%err, feed = %f.feed_url, "get_feed_by_url failed during OPML global-cap check");
5222                false
5223            }
5224        };
5225        if is_new {
5226            if let Some(g) = global_headroom.as_mut() {
5227                if *g <= 0 {
5228                    trimmed_over_global += 1;
5229                    continue;
5230                }
5231                *g -= 1;
5232            }
5233        }
5234
5235        // Passed both caps: consume the per-DID headroom now that the feed is
5236        // actually being imported.
5237        if let Some(h) = headroom.as_mut() {
5238            *h -= 1;
5239        }
5240
5241        let mut sub = Subscription::new(f.feed_url.clone(), now.clone());
5242        sub.title = f.title.clone();
5243        sub.site_url = f.site_url.clone();
5244        sub.folder = f
5245            .folder
5246            .as_ref()
5247            .and_then(|name| folder_uris.get(name).cloned());
5248        subs.push(sub);
5249        // Same support ticket as the single-add path: no `feeds` row means the
5250        // poller never selects this subscription, so the import looks like it
5251        // worked and the feed silently never updates. Counted as well as logged,
5252        // because one line per feed in a 200-feed import is not something anyone
5253        // reads — the count goes to the reader.
5254        if let Err(err) = store::upsert_feed(
5255            pool,
5256            &store::NewFeed {
5257                url: f.feed_url.clone(),
5258                title: f.title.clone(),
5259                site_url: f.site_url.clone(),
5260                ..Default::default()
5261            },
5262        )
5263        .await
5264        {
5265            warn!(%err, %did, url = %f.feed_url, "OPML import could not cache a feed; \
5266                                                  it will not be polled");
5267            uncached += 1;
5268        }
5269    }
5270
5271    // **A failed PDS write is not an import.**
5272    //
5273    // The subscriptions live in the reader's repo; a local `feeds` row is just a
5274    // poller hint. This used to `warn!` and then report "Imported N feeds"
5275    // regardless, so a total failure read as a total success — and the reader
5276    // would only discover otherwise on their next visit, with an empty sidebar.
5277    let pds_written = match state.repo().add_subscriptions_bulk(&did, &subs).await {
5278        Ok(rkeys) => {
5279            info!(%did, count = rkeys.len(), skipped = skipped_private.len(), "imported OPML subscriptions to PDS (batched)");
5280            true
5281        }
5282        Err(err) => {
5283            warn!(%err, %did, "OPML PDS batch write failed (feeds cached locally)");
5284            false
5285        }
5286    };
5287    if !pds_written {
5288        return Ok(Redirect::to(&format!(
5289            "/?flash={}",
5290            qenc(
5291                "Could not save those subscriptions to your PDS, so nothing was imported. \
5292                 Try again in a moment."
5293            )
5294        ))
5295        .into_response());
5296    }
5297
5298    // Report the import count, plus any private/paid feeds skipped as unsupported.
5299    let mut flash = format!("Imported {} feeds", subs.len());
5300    if uncached > 0 {
5301        flash.push_str(&format!(
5302            ". {uncached} of them could not be cached locally and may not update until the next import."
5303        ));
5304    }
5305    if trimmed_over_cap > 0 {
5306        flash.push_str(&format!(
5307            ". {trimmed_over_cap} feed(s) not imported: your subscription limit ({sub_cap}) was reached."
5308        ));
5309    }
5310    if trimmed_over_global > 0 {
5311        flash.push_str(&format!(
5312            ". {trimmed_over_global} feed(s) not imported: this instance is at its feed capacity right now."
5313        ));
5314    }
5315    if !skipped_private.is_empty() {
5316        flash.push_str(&format!(
5317            ". {} feed(s) skipped as private/paid: {} — not supported yet (public feeds only for now).",
5318            skipped_private.len(),
5319            skipped_private.join(", ")
5320        ));
5321    }
5322    if skipped_unsupported > 0 {
5323        // By count only — the URL is whatever the file said, and unlike the
5324        // private branch there is no public-safe label to give.
5325        flash.push_str(&format!(
5326            ". {skipped_unsupported} feed(s) skipped: not a kind of feed this instance can subscribe to."
5327        ));
5328    }
5329    Ok(Redirect::to(&format!("/?flash={}", qenc(&flash))).into_response())
5330}
5331
5332/// A public-safe label for a skipped private feed when it has no title: just the
5333/// host, so we never echo the secret-bearing path/query back to the user.
5334fn private_feed_label(url: &str) -> String {
5335    url::Url::parse(url)
5336        .ok()
5337        .and_then(|u| u.host_str().map(str::to_string))
5338        .unwrap_or_else(|| "a private feed".to_string())
5339}
5340
5341/// `GET /opml/export` — export the user's subscriptions + folders as OPML.
5342async fn export_opml(
5343    State(state): State<AppState>,
5344    headers: HeaderMap,
5345) -> Result<Response, WebError> {
5346    let did = match current_did(&state, &headers).await {
5347        Some(d) => d,
5348        None => return Ok(Redirect::to("/login").into_response()),
5349    };
5350
5351    // **An export must never be silently empty.** `unwrap_or_default` here turned
5352    // a failed read into a 200 carrying a zero-feed OPML file — the reader's
5353    // backup, blank, at exactly the moment they reached for it. That was survivable
5354    // while a truncated walk returned `Ok`; now that the walk refuses a short list,
5355    // this is the one caller that converts a refusal into data loss, and it is also
5356    // the recovery route the changelog points a locked-out reader at.
5357    let subs = match state.repo().list_subscriptions_sorted(&did).await {
5358        Ok(subs) => subs,
5359        Err(err) => {
5360            tracing::warn!(%err, did = %did, "refusing to export an OPML we could not read in full");
5361            return Ok(Redirect::to(&format!(
5362                "/manage?flash={}",
5363                qenc(EXPORT_INCOMPLETE_REFUSAL)
5364            ))
5365            .into_response());
5366        }
5367    };
5368    let folders = match state.repo().list_folders_sorted(&did).await {
5369        Ok(folders) => folders,
5370        Err(err) => {
5371            tracing::warn!(%err, did = %did, "refusing to export an OPML without its folders");
5372            return Ok(Redirect::to(&format!(
5373                "/manage?flash={}",
5374                qenc(EXPORT_INCOMPLETE_REFUSAL)
5375            ))
5376            .into_response());
5377        }
5378    };
5379    // The exporter matches a subscription's `folder` at-uri against the folder's
5380    // pair key; our folder pairs are keyed by rkey, so rebuild them as at-uris.
5381    let folder_pairs: Vec<(String, Folder)> = folders
5382        .into_iter()
5383        .map(|(rkey, f)| (folder_uri(&did, &rkey), f))
5384        .collect();
5385
5386    let body = opml::to_opml(&subs, &folder_pairs);
5387    let mut resp = (StatusCode::OK, body).into_response();
5388    resp.headers_mut().insert(
5389        header::CONTENT_TYPE,
5390        "text/x-opml; charset=utf-8".parse().unwrap(),
5391    );
5392    resp.headers_mut().insert(
5393        header::CONTENT_DISPOSITION,
5394        "attachment; filename=\"featherreader-subscriptions.opml\""
5395            .parse()
5396            .unwrap(),
5397    );
5398    Ok(resp)
5399}
5400
5401// ---------------------------------------------------------------------------
5402// Signed session cookie (HMAC-SHA256, dependency-free)
5403// ---------------------------------------------------------------------------
5404
5405/// Set a `Set-Cookie` header on a response (append, so logout+redirect compose).
5406fn set_cookie(resp: &mut Response, cookie: &str) {
5407    if let Ok(value) = axum::http::HeaderValue::from_str(cookie) {
5408        resp.headers_mut()
5409            .append(axum::http::header::SET_COOKIE, value);
5410    }
5411}
5412
5413/// Whether the request came from htmx (the `HX-Request` header).
5414fn is_htmx(headers: &HeaderMap) -> bool {
5415    headers
5416        .get("HX-Request")
5417        .is_some_and(|v| v.as_bytes().eq_ignore_ascii_case(b"true"))
5418}
5419
5420/// Whether a mark-read / star request originated from the single-entry READER
5421/// (as opposed to the list view). The reader's forms tag themselves with
5422/// `X-FR-Reader: 1` via `hx-headers`; the list view's do not. This selects the
5423/// swap fragment: the reader gets an out-of-band action-bar update (its `<li>`
5424/// isn't in the DOM), the list gets the row (`entry_row.html`).
5425fn is_reader_request(headers: &HeaderMap) -> bool {
5426    headers
5427        .get("X-FR-Reader")
5428        .is_some_and(|v| v.as_bytes() == b"1")
5429}
5430
5431/// A tiny, self-contained signed-cookie layer: HMAC-SHA256 over an opaque,
5432/// server-minted **session id** (never the DID — so the cookie can't be forged
5433/// from a resolved victim DID; forging it needs the HMAC secret *and* a live
5434/// server-side session id).
5435mod cookie {
5436    use super::{HeaderMap, SESSION_COOKIE};
5437
5438    /// Sign a session id into a `Set-Cookie` header value: `fr_session=<sid>.<sig>`.
5439    pub fn sign_session(sid: &str, secret: &str) -> String {
5440        sign_value(SESSION_COOKIE, sid, secret, 2_592_000)
5441    }
5442
5443    /// Verify the request's session cookie and return the session id it carries.
5444    pub fn verify_session(headers: &HeaderMap, secret: &str) -> Option<String> {
5445        verify_value(headers, SESSION_COOKIE, secret)
5446    }
5447
5448    /// The HMAC message binding the cookie NAME to its value (`name || 0x00 ||
5449    /// value`), so a signature minted for one cookie can't verify under another —
5450    /// e.g. a value validly signed as `fr_invite` is not accepted as `fr_session`.
5451    /// The NUL separator can't appear in a cookie name, so the encoding is
5452    /// unambiguous.
5453    fn cookie_hmac_msg(name: &str, value: &str) -> Vec<u8> {
5454        let mut msg = Vec::with_capacity(name.len() + 1 + value.len());
5455        msg.extend_from_slice(name.as_bytes());
5456        msg.push(0);
5457        msg.extend_from_slice(value.as_bytes());
5458        msg
5459    }
5460
5461    /// Sign an arbitrary string `value` into a `Set-Cookie` header for `name`,
5462    /// HMAC-SHA256 over `name || 0x00 || value`: `name=<b64url(value)>.<sig>`. The
5463    /// generic form behind both the session cookie and the short-lived invite
5464    /// cookie; domain-separating by name keeps a signature valid only for the
5465    /// cookie it was minted for.
5466    pub fn sign_value(name: &str, value: &str, secret: &str, max_age_secs: i64) -> String {
5467        let sig = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(name, value));
5468        let b64 = b64url_encode(value.as_bytes());
5469        format!(
5470            "{name}={b64}.{sig}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age={max_age_secs}"
5471        )
5472    }
5473
5474    /// Verify + read a value out of the named signed cookie (`None` on absent /
5475    /// tampered / forged / cross-cookie). The generic form behind both readers.
5476    pub fn verify_value(headers: &HeaderMap, name: &str, secret: &str) -> Option<String> {
5477        let raw = cookie_value(headers, name)?;
5478        let (b64, sig) = raw.split_once('.')?;
5479        let bytes = b64url_decode(b64)?;
5480        let value = String::from_utf8(bytes).ok()?;
5481        let expected = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(name, &value));
5482        if constant_time_eq(expected.as_bytes(), sig.as_bytes()) {
5483            Some(value)
5484        } else {
5485            None
5486        }
5487    }
5488
5489    /// Sign an arbitrary `value` into an opaque, URL-safe token string
5490    /// `b64url(value).<sig>` (HMAC-SHA256 over `label || 0x00 || value`). Unlike
5491    /// [`sign_value`] this is NOT a `Set-Cookie` header — it's a bare token for a
5492    /// URL query param (the bot's claim link). `label` domain-separates it from
5493    /// the cookies so a token can't be replayed as a cookie value.
5494    pub fn sign_token(label: &str, value: &str, secret: &str) -> String {
5495        let sig = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(label, value));
5496        let b64 = b64url_encode(value.as_bytes());
5497        format!("{b64}.{sig}")
5498    }
5499
5500    /// Verify a token minted by [`sign_token`] and return the wrapped value
5501    /// (`None` on tamper / forge / malformed). Constant-time signature compare.
5502    pub fn verify_token(label: &str, token: &str, secret: &str) -> Option<String> {
5503        let (b64, sig) = token.split_once('.')?;
5504        let bytes = b64url_decode(b64)?;
5505        let value = String::from_utf8(bytes).ok()?;
5506        let expected = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(label, &value));
5507        if constant_time_eq(expected.as_bytes(), sig.as_bytes()) {
5508            Some(value)
5509        } else {
5510            None
5511        }
5512    }
5513
5514    /// Pull one cookie value out of the `Cookie` request header.
5515    fn cookie_value(headers: &HeaderMap, name: &str) -> Option<String> {
5516        let header = headers.get(axum::http::header::COOKIE)?.to_str().ok()?;
5517        for part in header.split(';') {
5518            let part = part.trim();
5519            if let Some((k, v)) = part.split_once('=') {
5520                if k == name {
5521                    return Some(v.to_string());
5522                }
5523            }
5524        }
5525        None
5526    }
5527
5528    /// Constant-time byte comparison (avoid signature-timing leaks). Public
5529    /// within the module so the bot-secret bearer check reuses the exact same
5530    /// comparator as the cookie/token HMAC checks (one implementation to audit).
5531    pub fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
5532        if a.len() != b.len() {
5533            return false;
5534        }
5535        let mut diff = 0u8;
5536        for (x, y) in a.iter().zip(b.iter()) {
5537            diff |= x ^ y;
5538        }
5539        diff == 0
5540    }
5541
5542    // -- URL-safe base64 (no padding), std-only --------------------------------
5543
5544    const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
5545
5546    fn b64url_encode(input: &[u8]) -> String {
5547        let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
5548        for chunk in input.chunks(3) {
5549            let b = [
5550                chunk[0],
5551                *chunk.get(1).unwrap_or(&0),
5552                *chunk.get(2).unwrap_or(&0),
5553            ];
5554            let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | (b[2] as u32);
5555            out.push(B64[((n >> 18) & 63) as usize] as char);
5556            out.push(B64[((n >> 12) & 63) as usize] as char);
5557            if chunk.len() > 1 {
5558                out.push(B64[((n >> 6) & 63) as usize] as char);
5559            }
5560            if chunk.len() > 2 {
5561                out.push(B64[(n & 63) as usize] as char);
5562            }
5563        }
5564        out
5565    }
5566
5567    fn b64url_decode(input: &str) -> Option<Vec<u8>> {
5568        fn val(c: u8) -> Option<u32> {
5569            match c {
5570                b'A'..=b'Z' => Some((c - b'A') as u32),
5571                b'a'..=b'z' => Some((c - b'a' + 26) as u32),
5572                b'0'..=b'9' => Some((c - b'0' + 52) as u32),
5573                b'-' => Some(62),
5574                b'_' => Some(63),
5575                _ => None,
5576            }
5577        }
5578        let bytes = input.as_bytes();
5579        let mut out = Vec::with_capacity(input.len() / 4 * 3 + 2);
5580        for chunk in bytes.chunks(4) {
5581            let mut n = 0u32;
5582            let mut valid = 0;
5583            for (i, &c) in chunk.iter().enumerate() {
5584                n |= val(c)? << (18 - 6 * i);
5585                valid += 1;
5586            }
5587            out.push((n >> 16) as u8);
5588            if valid > 2 {
5589                out.push((n >> 8) as u8);
5590            }
5591            if valid > 3 {
5592                out.push(n as u8);
5593            }
5594        }
5595        Some(out)
5596    }
5597
5598    // -- HMAC-SHA256, std-only -------------------------------------------------
5599
5600    /// HMAC-SHA256(key, msg) as lowercase hex.
5601    fn hmac_sha256_hex(key: &[u8], msg: &[u8]) -> String {
5602        const BLOCK: usize = 64;
5603        let mut k = [0u8; BLOCK];
5604        if key.len() > BLOCK {
5605            let d = sha256(key);
5606            k[..32].copy_from_slice(&d);
5607        } else {
5608            k[..key.len()].copy_from_slice(key);
5609        }
5610        let mut ipad = [0x36u8; BLOCK];
5611        let mut opad = [0x5cu8; BLOCK];
5612        for i in 0..BLOCK {
5613            ipad[i] ^= k[i];
5614            opad[i] ^= k[i];
5615        }
5616        let mut inner = Vec::with_capacity(BLOCK + msg.len());
5617        inner.extend_from_slice(&ipad);
5618        inner.extend_from_slice(msg);
5619        let inner_hash = sha256(&inner);
5620        let mut outer = Vec::with_capacity(BLOCK + 32);
5621        outer.extend_from_slice(&opad);
5622        outer.extend_from_slice(&inner_hash);
5623        let mac = sha256(&outer);
5624        let mut hex = String::with_capacity(64);
5625        for b in mac {
5626            hex.push_str(&format!("{b:02x}"));
5627        }
5628        hex
5629    }
5630
5631    /// SHA-256 (FIPS 180-4), std-only.
5632    fn sha256(data: &[u8]) -> [u8; 32] {
5633        const K: [u32; 64] = [
5634            0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4,
5635            0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe,
5636            0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f,
5637            0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7,
5638            0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc,
5639            0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b,
5640            0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116,
5641            0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
5642            0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7,
5643            0xc67178f2,
5644        ];
5645        let mut h: [u32; 8] = [
5646            0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab,
5647            0x5be0cd19,
5648        ];
5649
5650        let bit_len = (data.len() as u64) * 8;
5651        let mut msg = data.to_vec();
5652        msg.push(0x80);
5653        while msg.len() % 64 != 56 {
5654            msg.push(0);
5655        }
5656        msg.extend_from_slice(&bit_len.to_be_bytes());
5657
5658        for block in msg.chunks(64) {
5659            let mut w = [0u32; 64];
5660            for i in 0..16 {
5661                w[i] = u32::from_be_bytes([
5662                    block[i * 4],
5663                    block[i * 4 + 1],
5664                    block[i * 4 + 2],
5665                    block[i * 4 + 3],
5666                ]);
5667            }
5668            for i in 16..64 {
5669                let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3);
5670                let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10);
5671                w[i] = w[i - 16]
5672                    .wrapping_add(s0)
5673                    .wrapping_add(w[i - 7])
5674                    .wrapping_add(s1);
5675            }
5676            let mut a = h;
5677            for i in 0..64 {
5678                let s1 = a[4].rotate_right(6) ^ a[4].rotate_right(11) ^ a[4].rotate_right(25);
5679                let ch = (a[4] & a[5]) ^ ((!a[4]) & a[6]);
5680                let t1 = a[7]
5681                    .wrapping_add(s1)
5682                    .wrapping_add(ch)
5683                    .wrapping_add(K[i])
5684                    .wrapping_add(w[i]);
5685                let s0 = a[0].rotate_right(2) ^ a[0].rotate_right(13) ^ a[0].rotate_right(22);
5686                let maj = (a[0] & a[1]) ^ (a[0] & a[2]) ^ (a[1] & a[2]);
5687                let t2 = s0.wrapping_add(maj);
5688                a[7] = a[6];
5689                a[6] = a[5];
5690                a[5] = a[4];
5691                a[4] = a[3].wrapping_add(t1);
5692                a[3] = a[2];
5693                a[2] = a[1];
5694                a[1] = a[0];
5695                a[0] = t1.wrapping_add(t2);
5696            }
5697            for i in 0..8 {
5698                h[i] = h[i].wrapping_add(a[i]);
5699            }
5700        }
5701
5702        let mut out = [0u8; 32];
5703        for (i, word) in h.iter().enumerate() {
5704            out[i * 4..i * 4 + 4].copy_from_slice(&word.to_be_bytes());
5705        }
5706        out
5707    }
5708
5709    #[cfg(test)]
5710    mod tests {
5711        use super::*;
5712
5713        #[test]
5714        fn sha256_known_vector() {
5715            let d = sha256(b"abc");
5716            let hex: String = d.iter().map(|b| format!("{b:02x}")).collect();
5717            assert_eq!(
5718                hex,
5719                "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
5720            );
5721        }
5722
5723        #[test]
5724        fn hmac_known_vector() {
5725            let mac = hmac_sha256_hex(b"Jefe", b"what do ya want for nothing?");
5726            assert_eq!(
5727                mac,
5728                "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843"
5729            );
5730        }
5731
5732        #[test]
5733        fn sign_verify_round_trips() {
5734            let secret = "test-secret";
5735            let sid = "9f2c-opaque-session-id";
5736            let cookie = sign_session(sid, secret);
5737            let pair = cookie.split(';').next().unwrap().to_string();
5738            let mut headers = HeaderMap::new();
5739            headers.insert(axum::http::header::COOKIE, pair.parse().unwrap());
5740            assert_eq!(verify_session(&headers, secret).as_deref(), Some(sid));
5741            // Wrong secret → rejected (an attacker without the HMAC key can't forge).
5742            assert!(verify_session(&headers, "other-secret").is_none());
5743        }
5744
5745        #[test]
5746        fn forged_and_tampered_cookies_are_rejected() {
5747            let secret = "test-secret";
5748
5749            // 1. A fully forged cookie: attacker knows a victim's DID/sid but not
5750            //    the secret, so an arbitrary signature must not verify.
5751            let forged = format!(
5752                "{SESSION_COOKIE}={}.{}",
5753                b64url_encode(b"attacker-chosen-sid"),
5754                "deadbeef".repeat(8) // 64 hex chars, wrong sig
5755            );
5756            let mut headers = HeaderMap::new();
5757            headers.insert(axum::http::header::COOKIE, forged.parse().unwrap());
5758            assert!(verify_session(&headers, secret).is_none());
5759
5760            // 2. A tampered cookie: take a VALID cookie and mutate the sid while
5761            //    keeping the original signature — must not verify.
5762            let cookie = sign_session("real-sid", secret);
5763            let pair = cookie.split(';').next().unwrap();
5764            let (_b64, sig) = pair.split_once('=').unwrap().1.split_once('.').unwrap();
5765            let tampered = format!(
5766                "{SESSION_COOKIE}={}.{}",
5767                b64url_encode(b"different-sid"),
5768                sig
5769            );
5770            let mut headers2 = HeaderMap::new();
5771            headers2.insert(axum::http::header::COOKIE, tampered.parse().unwrap());
5772            assert!(verify_session(&headers2, secret).is_none());
5773        }
5774
5775        #[test]
5776        fn b64url_round_trips() {
5777            for s in ["did:plc:abc", "", "a", "ab", "abc", "abcd"] {
5778                let enc = b64url_encode(s.as_bytes());
5779                assert_eq!(b64url_decode(&enc).unwrap(), s.as_bytes());
5780            }
5781        }
5782    }
5783}
5784
5785// ---------------------------------------------------------------------------
5786// Small store helpers local to the web layer
5787// ---------------------------------------------------------------------------
5788
5789/// Fetch a single cached entry by id — SCOPED to `did`'s subscriptions.
5790///
5791/// Returns `None` (→ 404 at the handler) if the entry does not exist OR if
5792/// `did` does not subscribe to its feed. This is the per-DID read gate for the
5793/// `GET /entries/:id` reader and the htmx row rebuild: the shared cache is
5794/// deduped by URL, but no DID can read another DID's cached article.
5795///
5796/// **The only `SELECT e.*` left, and deliberately so.** This is the one surface
5797/// that renders `content_html`, and it fetches exactly one row. The list views
5798/// go through [`store::list_entries`], which is both paged and body-free — see
5799/// [`store::EntryListRow`] for why they had to stop sharing this projection.
5800async fn get_entry_by_id(
5801    pool: &store::Pool,
5802    did: &str,
5803    id: i64,
5804) -> anyhow::Result<Option<store::Entry>> {
5805    let entry = sqlx::query_as::<_, store::Entry>(
5806        r#"
5807        SELECT e.* FROM entries e
5808        WHERE e.id = ?2
5809          AND EXISTS (
5810              SELECT 1 FROM sub_ref sr
5811              WHERE sr.did = ?1 AND sr.feed_id = e.feed_id
5812          )
5813        "#,
5814    )
5815    .bind(did)
5816    .bind(id)
5817    .fetch_optional(pool)
5818    .await?;
5819    Ok(entry)
5820}
5821
5822/// Whether `entry_id` is marked read for `did` (absent state row = unread).
5823async fn entry_is_read(pool: &store::Pool, did: &str, entry_id: i64) -> anyhow::Result<bool> {
5824    let read: Option<bool> =
5825        sqlx::query_scalar("SELECT read FROM entry_state WHERE did = ?1 AND entry_id = ?2")
5826            .bind(did)
5827            .bind(entry_id)
5828            .fetch_optional(pool)
5829            .await?
5830            .flatten();
5831    Ok(read.unwrap_or(false))
5832}
5833
5834/// Whether `entry_id` is starred for `did` (absent state row = not starred).
5835async fn entry_is_starred(pool: &store::Pool, did: &str, entry_id: i64) -> anyhow::Result<bool> {
5836    let starred: Option<bool> =
5837        sqlx::query_scalar("SELECT starred FROM entry_state WHERE did = ?1 AND entry_id = ?2")
5838            .bind(did)
5839            .bind(entry_id)
5840            .fetch_optional(pool)
5841            .await?
5842            .flatten();
5843    Ok(starred.unwrap_or(false))
5844}
5845
5846/// Feed display title for one entry's feed id (via a single lookup).
5847async fn feed_title_by_entry(pool: &store::Pool, feed_id: i64) -> String {
5848    match sqlx::query_as::<_, store::Feed>("SELECT * FROM feeds WHERE id = ?1")
5849        .bind(feed_id)
5850        .fetch_optional(pool)
5851        .await
5852    {
5853        Ok(Some(f)) => display_title(f.title.as_deref(), &f.url),
5854        _ => String::new(),
5855    }
5856}
5857
5858/// Rebuild an [`EntryRow`] for an htmx swap after a read/star toggle. `read` may
5859/// be forced (mark-read path) or looked up (`None` — star path).
5860async fn build_entry_row(
5861    pool: &store::Pool,
5862    did: &str,
5863    id: i64,
5864    read: Option<bool>,
5865) -> anyhow::Result<Option<EntryRow>> {
5866    let entry = match get_entry_by_id(pool, did, id).await? {
5867        Some(e) => e,
5868        None => return Ok(None),
5869    };
5870    let read = match read {
5871        Some(r) => r,
5872        None => entry_is_read(pool, did, id).await?,
5873    };
5874    let starred = entry_is_starred(pool, did, id).await?;
5875    Ok(Some(EntryRow {
5876        id: entry.id,
5877        title: entry
5878            .title
5879            .clone()
5880            .filter(|t| !t.trim().is_empty())
5881            .unwrap_or_else(|| "(untitled)".to_string()),
5882        feed_title: feed_title_by_entry(pool, entry.feed_id).await,
5883        published: display_date(entry.published.as_deref()),
5884        read,
5885        starred,
5886        link: SafeLink::entry(id, ""),
5887        cached: true,
5888        rkey: String::new(),
5889    }))
5890}
5891
5892/// RFC3339 "now" (UTC) — shared by handlers that stamp/compare timestamps.
5893fn now_rfc3339() -> String {
5894    chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
5895}
5896
5897#[cfg(test)]
5898mod tests {
5899    use super::*;
5900
5901    #[test]
5902    fn qenc_encodes_reserved() {
5903        assert_eq!(qenc("a b"), "a%20b");
5904        assert_eq!(
5905            qenc("https://example.com/feed.xml"),
5906            "https%3A%2F%2Fexample.com%2Ffeed.xml"
5907        );
5908        assert_eq!(
5909            qenc("at://did:plc:x/c/r"),
5910            "at%3A%2F%2Fdid%3Aplc%3Ax%2Fc%2Fr"
5911        );
5912        // Unreserved chars pass through untouched.
5913        assert_eq!(qenc("A-Za-z0-9-_.~"), "A-Za-z0-9-_.~");
5914    }
5915
5916    #[test]
5917    fn folder_uri_shape() {
5918        assert_eq!(
5919            folder_uri("did:plc:abc", "3kfolder"),
5920            "at://did:plc:abc/community.lexicon.rss.folder/3kfolder"
5921        );
5922    }
5923
5924    // -- public-feeds-only: private/paid feeds are refused --------------------
5925
5926    #[test]
5927    fn private_feeds_are_classified_private_across_providers() {
5928        // The add + OPML paths both gate on this classifier; assert it flags a
5929        // spread of paid providers (newsletters + private podcasts) and the
5930        // generic credential-in-URL shapes.
5931        for url in [
5932            "https://author.substack.com/feed/private/deadbeefcafe1234",
5933            "https://www.patreon.com/rss/author?auth=Zm9vYmFyc2VjcmV0dG9rZW4",
5934            "https://blog.ghost.io/rss/?uuid=1f2e3d4c-5b6a-7089-90ab-cdef01234567",
5935            "https://feeds.supportingcast.fm/show/abcdef0123456789abcdef01",
5936            "https://example.com/feed?token=Zm9vYmFyc2VjcmV0",
5937            "https://user:pass@example.com/feed",
5938        ] {
5939            assert!(
5940                feed::classify_feed_privacy(url).is_private(),
5941                "expected private: {url}"
5942            );
5943        }
5944    }
5945
5946    #[test]
5947    fn public_feeds_stay_public() {
5948        for url in [
5949            "https://author.substack.com/feed",
5950            "https://wordpress.example.com/feed/",
5951            "https://example.com/rss.xml",
5952            "https://example.org/atom.xml",
5953            // YouTube channel/playlist RSS is fully public — must not false-block.
5954            "https://www.youtube.com/feeds/videos.xml?channel_id=UC-lHJZR3Gqxm24_Vd_AJ5Yw",
5955            "https://www.youtube.com/feeds/videos.xml?playlist_id=PLFgquLnL59alCl_2TQvOiD5Vgm1",
5956        ] {
5957            assert!(
5958                !feed::classify_feed_privacy(url).is_private(),
5959                "expected public: {url}"
5960            );
5961        }
5962    }
5963
5964    #[test]
5965    fn private_feed_label_is_public_safe_host_only() {
5966        // The OPML skip report must never echo the secret path/query, only the host.
5967        let label =
5968            private_feed_label("https://author.substack.com/feed/private/deadbeefcafe1234token");
5969        assert_eq!(label, "author.substack.com");
5970        assert!(!label.contains("deadbeefcafe1234token"));
5971        assert!(!label.contains("/private/"));
5972        // An unparseable URL degrades to a generic label.
5973        assert_eq!(private_feed_label("not a url"), "a private feed");
5974    }
5975
5976    #[test]
5977    fn refusal_message_promises_nothing_stored() {
5978        assert!(PRIVATE_FEED_REFUSAL.contains("not saved or sent anywhere"));
5979        assert!(PRIVATE_FEED_REFUSAL.contains("public feeds"));
5980    }
5981
5982    #[test]
5983    fn scope_query_preserves_context() {
5984        let q = EntryQuery {
5985            feed: Some("https://example.com/feed.xml".to_string()),
5986            folder: None,
5987            view: Some("all".to_string()),
5988        };
5989        let s = scope_query(&q);
5990        assert!(s.contains("feed=https%3A%2F%2Fexample.com%2Ffeed.xml"));
5991        assert!(s.contains("view=all"));
5992
5993        // Default view is omitted.
5994        let q2 = EntryQuery {
5995            feed: None,
5996            folder: None,
5997            view: Some("unread".to_string()),
5998        };
5999        assert_eq!(scope_query(&q2), "");
6000    }
6001
6002    // -- closed-beta invite gate + rate-limit + cache-control ------------------
6003
6004    use axum::body::Body;
6005    use axum::http::Request;
6006    use tower::ServiceExt; // for `oneshot`
6007
6008    /// Build an [`AppState`] over a fresh in-memory DB, seeding the given admin
6009    /// DIDs (via ALLOWED_DIDS → ensure_seed) and a fixed cookie secret so tests
6010    /// can forge matching cookies.
6011    async fn test_state(allowed: &[&str]) -> AppState {
6012        let db = store::init_url("sqlite::memory:").await.unwrap();
6013        let dids: Vec<String> = allowed.iter().map(|s| s.to_string()).collect();
6014        store::ensure_seed(&db, &dids).await.unwrap();
6015        let config = Config {
6016            allowed_dids: dids,
6017            cookie_secret: "test-cookie-secret-000".to_string(),
6018            beta_cap: 3,
6019            ..Config::default()
6020        };
6021        AppState::new(config, db).unwrap()
6022    }
6023
6024    /// A `Cookie` header carrying a valid signed session for `sid` (the sid is
6025    /// looked up in the registry, so create the session first).
6026    fn session_cookie(state: &AppState, did: &str, handle: Option<&str>) -> String {
6027        let sid = state.sessions.create(Session {
6028            did: did.to_string(),
6029            handle: handle.map(str::to_string),
6030        });
6031        let sc = cookie::sign_session(&sid, &state.config.cookie_secret);
6032        sc.split(';').next().unwrap().to_string()
6033    }
6034
6035    /// The bucket map is bounded by COUNT, not only by idle time. An hour is a
6036    /// long time to accept distinct source IPs on two unauthenticated guarded
6037    /// routes.
6038    #[test]
6039    fn the_rate_limit_map_is_bounded() {
6040        let rl = RateLimiter::shared();
6041        let now = Instant::now();
6042        for i in 0..(MAX_RATE_BUCKETS + 2_000) {
6043            // Distinct IPv6 addresses, all "seen" at increasing times so the LRU
6044            // ordering below is well-defined.
6045            let ip: IpAddr = format!("2001:db8::{i:x}").parse().unwrap();
6046            rl.check_at(ip, now + Duration::from_millis(i as u64));
6047        }
6048        let len = rl.inner.lock().unwrap().buckets.len();
6049        assert!(
6050            len <= MAX_RATE_BUCKETS,
6051            "the rate-limit map grew to {len}, past its {MAX_RATE_BUCKETS} cap"
6052        );
6053    }
6054
6055    /// Eviction must not hand a throttled attacker a fresh burst.
6056    ///
6057    /// The bound is LRU, so the one bucket an attacker can never evict is their
6058    /// own — it is the most recently touched thing in the map. If this inverted,
6059    /// the size cap would become a rate-limit bypass: spray addresses until the
6060    /// map overflows, then resume.
6061    #[test]
6062    fn flooding_the_map_does_not_reset_the_flooders_own_bucket() {
6063        let rl = RateLimiter::shared();
6064        let base = Instant::now();
6065        let attacker: IpAddr = "203.0.113.7".parse().unwrap();
6066        // Nanosecond steps: enough to keep the LRU ordering strictly increasing,
6067        // far too little for `RATE_REFILL_PER_SEC` to hand back a token. A
6068        // millisecond step made the whole flood take a second, and the refill —
6069        // working correctly — then looked exactly like an eviction bypass.
6070        let at = |n: u64| base + Duration::from_nanos(n);
6071
6072        // Spend the burst. `RATE_BURST` allowed, then refused.
6073        for i in 0..(RATE_BURST as u64) {
6074            assert!(rl.check_at(attacker, at(i)));
6075        }
6076        assert!(
6077            !rl.check_at(attacker, at(RATE_BURST as u64)),
6078            "burst was not exhausted; the rest of this test proves nothing"
6079        );
6080
6081        // Now overflow the map from other addresses, interleaving the attacker
6082        // so their bucket stays hot — the realistic shape of the attack.
6083        for i in 0..(MAX_RATE_BUCKETS + 2_000) {
6084            let t = at(100 + i as u64 * 2);
6085            let ip: IpAddr = format!("2001:db8:1::{i:x}").parse().unwrap();
6086            rl.check_at(ip, t);
6087            assert!(
6088                !rl.check_at(attacker, t),
6089                "the attacker got a token back after evictions at i={i}"
6090            );
6091        }
6092    }
6093
6094    /// The idle sweep is amortised, not per-request. It used to be an O(n) scan
6095    /// of the whole map on every guarded request, on one shared core.
6096    #[test]
6097    fn the_idle_sweep_does_not_run_on_every_request() {
6098        let rl = RateLimiter::shared();
6099        let start = Instant::now();
6100        let a: IpAddr = "198.51.100.1".parse().unwrap();
6101        let b: IpAddr = "198.51.100.2".parse().unwrap();
6102
6103        rl.check_at(a, start);
6104        // `b` arrives an hour later: `a` is now idle past `RATE_IDLE_EVICT`, but
6105        // the sweep interval has elapsed too, so this request does sweep it.
6106        rl.check_at(b, start + RATE_IDLE_EVICT + Duration::from_secs(1));
6107        assert!(
6108            !rl.inner.lock().unwrap().buckets.contains_key(&a),
6109            "an idle bucket survived a sweep that was due"
6110        );
6111
6112        // A second request moments later must NOT re-sweep — `b` is still there,
6113        // and the recorded sweep time must not have moved.
6114        let before = rl.inner.lock().unwrap().last_sweep;
6115        rl.check_at(b, start + RATE_IDLE_EVICT + Duration::from_secs(2));
6116        assert_eq!(
6117            rl.inner.lock().unwrap().last_sweep,
6118            before,
6119            "the sweep ran again within the interval"
6120        );
6121    }
6122
6123    #[test]
6124    fn rate_limited_paths_match_expected() {
6125        use axum::http::Method;
6126        assert!(is_rate_limited_path("/login", &Method::GET));
6127        assert!(is_rate_limited_path("/login", &Method::POST));
6128        assert!(is_rate_limited_path("/beta/redeem", &Method::POST));
6129        assert!(is_rate_limited_path("/subscriptions", &Method::POST));
6130        assert!(is_rate_limited_path("/opml", &Method::POST));
6131        assert!(is_rate_limited_path("/read-all", &Method::POST));
6132        assert!(is_rate_limited_path("/admin/invites", &Method::POST));
6133        assert!(is_rate_limited_path("/entries/42/read", &Method::POST));
6134        assert!(is_rate_limited_path("/entries/42/star", &Method::POST));
6135        // Read-only navigation is NOT limited.
6136        assert!(!is_rate_limited_path("/", &Method::GET));
6137        assert!(!is_rate_limited_path("/about", &Method::GET));
6138        assert!(!is_rate_limited_path("/entries/42", &Method::GET));
6139        assert!(!is_rate_limited_path("/login", &Method::HEAD));
6140    }
6141
6142    #[test]
6143    fn rate_limiter_allows_burst_then_429s() {
6144        let rl = RateLimiter::shared();
6145        let ip: IpAddr = "203.0.113.7".parse().unwrap();
6146        // The full burst passes.
6147        for _ in 0..(RATE_BURST as usize) {
6148            assert!(rl.check(ip));
6149        }
6150        // The next one (no time elapsed → no refill) is rejected.
6151        assert!(!rl.check(ip));
6152        // A different IP has its own bucket.
6153        let ip2: IpAddr = "203.0.113.8".parse().unwrap();
6154        assert!(rl.check(ip2));
6155    }
6156
6157    #[test]
6158    fn client_ip_ignores_spoofed_xff_without_trusted_header() {
6159        // With NO trusted header configured, a client-supplied X-Forwarded-For
6160        // must be ignored entirely — the limiter keys on the real socket peer,
6161        // so an attacker can't mint a fresh bucket per forged XFF value.
6162        let mut h = HeaderMap::new();
6163        h.insert("x-forwarded-for", "198.51.100.9, 10.0.0.1".parse().unwrap());
6164        let sock: SocketAddr = "203.0.113.55:1234".parse().unwrap();
6165        assert_eq!(
6166            client_ip(&h, Some(&sock), None),
6167            Some("203.0.113.55".parse().unwrap()),
6168            "spoofed XFF must not override the socket peer"
6169        );
6170    }
6171
6172    #[test]
6173    fn client_ip_uses_trusted_header_last_hop() {
6174        // With a trusted proxy header configured, the client IP comes from THAT
6175        // header (the proxy overwrites any client copy). On a comma list we take
6176        // the RIGHT-most hop — the one the trusted proxy appended — so a
6177        // client-forged left-most value is ignored.
6178        let sock: SocketAddr = "10.0.0.1:1234".parse().unwrap();
6179
6180        let mut h = HeaderMap::new();
6181        h.insert("fly-client-ip", "198.51.100.9".parse().unwrap());
6182        assert_eq!(
6183            client_ip(&h, Some(&sock), Some("fly-client-ip")),
6184            Some("198.51.100.9".parse().unwrap())
6185        );
6186
6187        // Attacker prepends a forged hop; the trusted proxy appends the real one.
6188        let mut h2 = HeaderMap::new();
6189        h2.insert("x-forwarded-for", "1.2.3.4, 198.51.100.9".parse().unwrap());
6190        assert_eq!(
6191            client_ip(&h2, Some(&sock), Some("x-forwarded-for")),
6192            Some("198.51.100.9".parse().unwrap()),
6193            "must take the right-most (trusted) hop, not the forged left-most"
6194        );
6195
6196        // Trusted header absent → fall back to the socket peer.
6197        let h3 = HeaderMap::new();
6198        assert_eq!(
6199            client_ip(&h3, Some(&sock), Some("fly-client-ip")),
6200            Some("10.0.0.1".parse().unwrap())
6201        );
6202    }
6203
6204    #[test]
6205    fn invite_cookie_round_trips_and_rejects_tamper() {
6206        let secret = "test-cookie-secret-000";
6207        let sc = sign_invite("FEATHER-ABCDWXYZ", secret);
6208        let pair = sc.split(';').next().unwrap();
6209        let mut h = HeaderMap::new();
6210        h.insert(header::COOKIE, pair.parse().unwrap());
6211        assert_eq!(
6212            invite_cookie_code(&h, secret).as_deref(),
6213            Some("FEATHER-ABCDWXYZ")
6214        );
6215        // Wrong secret → rejected.
6216        assert!(invite_cookie_code(&h, "other").is_none());
6217    }
6218
6219    #[tokio::test]
6220    async fn preflight_valid_expired_and_full() {
6221        let state = test_state(&["did:plc:admin"]).await;
6222        // A minted, active code preflights OK.
6223        let code = store::mint_code(&state.db, "did:plc:admin", 3600)
6224            .await
6225            .unwrap();
6226        assert!(preflight_code(&state, &code).await.is_ok());
6227
6228        // A code whose expiry is in the past preflights as Expired. (mint_code
6229        // clamps negative ttl to 0, so back-date the row directly for a
6230        // deterministic past expiry.)
6231        let expired = store::mint_code(&state.db, "did:plc:admin", 3600)
6232            .await
6233            .unwrap();
6234        sqlx::query("UPDATE invite_codes SET expires_at = ?1 WHERE code = ?2")
6235            .bind(chrono::Utc::now().timestamp() - 3600)
6236            .bind(&expired)
6237            .execute(&state.db)
6238            .await
6239            .unwrap();
6240        assert_eq!(
6241            preflight_code(&state, &expired).await,
6242            Err(store::RedeemError::Expired)
6243        );
6244
6245        // Unknown code → NotFound.
6246        assert_eq!(
6247            preflight_code(&state, "FEATHER-NOPENOPE").await,
6248            Err(store::RedeemError::NotFound)
6249        );
6250
6251        // Fill to cap (cap=3; the admin seed already took 1 seat) then preflight
6252        // must report CapacityFull.
6253        store::grant_access(&state.db, "did:plc:b", None, "admin", None)
6254            .await
6255            .unwrap();
6256        store::grant_access(&state.db, "did:plc:c", None, "admin", None)
6257            .await
6258            .unwrap();
6259        assert_eq!(store::count_beta_access(&state.db).await.unwrap(), 3);
6260        assert_eq!(
6261            preflight_code(&state, &code).await,
6262            Err(store::RedeemError::CapacityFull)
6263        );
6264    }
6265
6266    // -- Bot claim link + shared-secret mint ---------------------------------
6267
6268    /// A test state with a configured bot secret (so `/bot/claims` is live).
6269    async fn bot_state(bot_secret: &str) -> AppState {
6270        let db = store::init_url("sqlite::memory:").await.unwrap();
6271        store::ensure_seed(&db, &["did:plc:admin".to_string()])
6272            .await
6273            .unwrap();
6274        let config = Config {
6275            allowed_dids: vec!["did:plc:admin".to_string()],
6276            cookie_secret: "test-cookie-secret-000".to_string(),
6277            beta_cap: 3,
6278            bot_secret: Some(bot_secret.to_string()),
6279            public_url: "https://feather-reader.com".to_string(),
6280            ..Config::default()
6281        };
6282        AppState::new(config, db).unwrap()
6283    }
6284
6285    #[test]
6286    fn claim_token_round_trips_and_rejects_tamper() {
6287        let secret = "test-cookie-secret-000";
6288        let token = sign_claim_token("FEATHER-ABCDWXYZ", secret);
6289        // No cookie framing — a bare URL-safe token.
6290        assert!(!token.contains(';'));
6291        assert_eq!(
6292            claim_token_code(&token, secret).as_deref(),
6293            Some("FEATHER-ABCDWXYZ")
6294        );
6295        // Wrong secret → rejected.
6296        assert!(claim_token_code(&token, "other").is_none());
6297        // Tampered token → rejected.
6298        let mut bad = token.clone();
6299        bad.push('x');
6300        assert!(claim_token_code(&bad, secret).is_none());
6301        // The token is NOT confidential: it is `b64url(code).<sig>`, so the code is
6302        // only base64-obscured (not verbatim, but TRIVIALLY decodable — anyone can
6303        // recover it WITHOUT the secret). The security is single-use + HMAC
6304        // integrity + rate-limit, not secrecy of the code. Assert the code half is
6305        // publicly decodable (a plain base64url decode, no secret involved).
6306        let (b64, _sig) = token.split_once('.').expect("token is b64.sig");
6307        assert_eq!(
6308            test_b64url_decode(b64).as_deref(),
6309            Some("FEATHER-ABCDWXYZ".as_bytes()),
6310            "the code half of the token is plain base64url, decodable by anyone"
6311        );
6312    }
6313
6314    /// Minimal URL-safe base64 (no padding) decoder for the test above, proving the
6315    /// claim token's code half needs NO secret to recover (it is not confidential).
6316    fn test_b64url_decode(input: &str) -> Option<Vec<u8>> {
6317        fn val(c: u8) -> Option<u32> {
6318            match c {
6319                b'A'..=b'Z' => Some((c - b'A') as u32),
6320                b'a'..=b'z' => Some((c - b'a' + 26) as u32),
6321                b'0'..=b'9' => Some((c - b'0' + 52) as u32),
6322                b'-' => Some(62),
6323                b'_' => Some(63),
6324                _ => None,
6325            }
6326        }
6327        let mut out = Vec::with_capacity(input.len() / 4 * 3);
6328        for chunk in input.as_bytes().chunks(4) {
6329            let mut n = 0u32;
6330            let mut bits = 0;
6331            for &c in chunk {
6332                n = (n << 6) | val(c)?;
6333                bits += 6;
6334            }
6335            let bytes = bits / 8;
6336            n <<= 24 - bits;
6337            for i in 0..bytes {
6338                out.push((n >> (16 - i * 8)) as u8);
6339            }
6340        }
6341        Some(out)
6342    }
6343
6344    #[tokio::test]
6345    async fn bot_mint_then_claim_grants_a_seat() {
6346        let state = bot_state("bot-secret-abcdef").await;
6347        let app = router(state.clone());
6348
6349        // 1. Mint a claim via the shared-secret endpoint.
6350        let resp = app
6351            .clone()
6352            .oneshot(
6353                Request::builder()
6354                    .method("POST")
6355                    .uri("/bot/claims")
6356                    .header("x-bot-secret", "bot-secret-abcdef")
6357                    .body(Body::empty())
6358                    .unwrap(),
6359            )
6360            .await
6361            .unwrap();
6362        assert_eq!(resp.status(), StatusCode::OK);
6363        let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
6364            .await
6365            .unwrap();
6366        let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
6367        let token = json["token"].as_str().unwrap().to_string();
6368        let url = json["url"].as_str().unwrap();
6369        assert!(url.starts_with("https://feather-reader.com/claim?t="));
6370        // The raw code is returned for the bot's records but not embedded in url.
6371        assert!(json["code"].as_str().unwrap().starts_with("FEATHER-"));
6372        assert!(!url.contains("FEATHER-"));
6373
6374        // 2. Follow the claim link → reserves the invite cookie + redirects to /login.
6375        let resp = app
6376            .clone()
6377            .oneshot(
6378                Request::builder()
6379                    .method("GET")
6380                    .uri(format!("/claim?t={}", qenc(&token)))
6381                    .body(Body::empty())
6382                    .unwrap(),
6383            )
6384            .await
6385            .unwrap();
6386        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
6387        assert_eq!(resp.headers().get(header::LOCATION).unwrap(), "/login");
6388        let set_cookie = resp
6389            .headers()
6390            .get(header::SET_COOKIE)
6391            .unwrap()
6392            .to_str()
6393            .unwrap();
6394        assert!(set_cookie.starts_with(INVITE_COOKIE), "{set_cookie}");
6395
6396        // 3. The reserved cookie carries the same code the token wrapped, and
6397        //    redeeming it (the callback's machinery) grants a seat.
6398        let code = claim_token_code(&token, &state.config.cookie_secret).unwrap();
6399        let out = store::redeem_code(
6400            &state.db,
6401            &code,
6402            "did:plc:follower",
6403            None,
6404            state.config.beta_cap,
6405        )
6406        .await
6407        .unwrap();
6408        assert_eq!(out, Ok(()));
6409        assert!(store::has_beta_access(&state.db, "did:plc:follower")
6410            .await
6411            .unwrap());
6412    }
6413
6414    #[tokio::test]
6415    async fn claim_with_invalid_token_bounces() {
6416        let state = bot_state("bot-secret-abcdef").await;
6417        let app = router(state);
6418        let resp = app
6419            .oneshot(
6420                Request::builder()
6421                    .method("GET")
6422                    .uri("/claim?t=not-a-real-token")
6423                    .body(Body::empty())
6424                    .unwrap(),
6425            )
6426            .await
6427            .unwrap();
6428        // Renders the invite page (200), NOT a redirect to /login.
6429        assert_eq!(resp.status(), StatusCode::OK);
6430    }
6431
6432    #[tokio::test]
6433    async fn claim_with_used_token_is_refused() {
6434        let state = bot_state("bot-secret-abcdef").await;
6435        // Mint a code + wrap it, then redeem it out from under the token.
6436        let code = store::mint_code(&state.db, "did:plc:admin", 3600)
6437            .await
6438            .unwrap();
6439        let token = sign_claim_token(&code, &state.config.cookie_secret);
6440        store::redeem_code(
6441            &state.db,
6442            &code,
6443            "did:plc:someone",
6444            None,
6445            state.config.beta_cap,
6446        )
6447        .await
6448        .unwrap()
6449        .unwrap();
6450        let app = router(state);
6451        let resp = app
6452            .oneshot(
6453                Request::builder()
6454                    .method("GET")
6455                    .uri(format!("/claim?t={}", qenc(&token)))
6456                    .body(Body::empty())
6457                    .unwrap(),
6458            )
6459            .await
6460            .unwrap();
6461        // A used code → the invite page (AlreadyRedeemed), not a fresh reservation.
6462        assert_eq!(resp.status(), StatusCode::OK);
6463        assert!(resp.headers().get(header::SET_COOKIE).is_none());
6464    }
6465
6466    #[tokio::test]
6467    async fn bot_claims_rejects_bad_and_missing_secret() {
6468        let state = bot_state("bot-secret-abcdef").await;
6469        let app = router(state);
6470        // Wrong secret.
6471        let resp = app
6472            .clone()
6473            .oneshot(
6474                Request::builder()
6475                    .method("POST")
6476                    .uri("/bot/claims")
6477                    .header("x-bot-secret", "wrong")
6478                    .body(Body::empty())
6479                    .unwrap(),
6480            )
6481            .await
6482            .unwrap();
6483        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
6484        // Missing secret.
6485        let resp = app
6486            .oneshot(
6487                Request::builder()
6488                    .method("POST")
6489                    .uri("/bot/claims")
6490                    .body(Body::empty())
6491                    .unwrap(),
6492            )
6493            .await
6494            .unwrap();
6495        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
6496    }
6497
6498    #[tokio::test]
6499    async fn bot_claims_disabled_when_secret_unset() {
6500        // test_state configures NO bot secret → the endpoint is off (503).
6501        let state = test_state(&["did:plc:admin"]).await;
6502        let app = router(state);
6503        let resp = app
6504            .oneshot(
6505                Request::builder()
6506                    .method("POST")
6507                    .uri("/bot/claims")
6508                    .header("x-bot-secret", "anything")
6509                    .body(Body::empty())
6510                    .unwrap(),
6511            )
6512            .await
6513            .unwrap();
6514        assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
6515    }
6516
6517    #[tokio::test]
6518    async fn bot_claims_refuses_at_capacity() {
6519        let state = bot_state("bot-secret-abcdef").await;
6520        // cap=3, admin seed took 1 seat. Grant 2 more to fill it.
6521        store::grant_access(&state.db, "did:plc:b", None, "admin", None)
6522            .await
6523            .unwrap();
6524        store::grant_access(&state.db, "did:plc:c", None, "admin", None)
6525            .await
6526            .unwrap();
6527        assert_eq!(store::count_beta_access(&state.db).await.unwrap(), 3);
6528        let app = router(state);
6529        let resp = app
6530            .oneshot(
6531                Request::builder()
6532                    .method("POST")
6533                    .uri("/bot/claims")
6534                    .header("x-bot-secret", "bot-secret-abcdef")
6535                    .body(Body::empty())
6536                    .unwrap(),
6537            )
6538            .await
6539            .unwrap();
6540        assert_eq!(resp.status(), StatusCode::CONFLICT);
6541        let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
6542            .await
6543            .unwrap();
6544        assert!(String::from_utf8_lossy(&bytes).contains("full"));
6545    }
6546
6547    #[tokio::test]
6548    async fn bot_claims_counts_outstanding_codes_against_cap() {
6549        let state = bot_state("bot-secret-abcdef").await;
6550        // cap=3, admin seed = 1 seat. Two outstanding active codes = 3 committed.
6551        store::mint_code(&state.db, "did:plc:admin", 3600)
6552            .await
6553            .unwrap();
6554        store::mint_code(&state.db, "did:plc:admin", 3600)
6555            .await
6556            .unwrap();
6557        let app = router(state);
6558        let resp = app
6559            .oneshot(
6560                Request::builder()
6561                    .method("POST")
6562                    .uri("/bot/claims")
6563                    .header("x-bot-secret", "bot-secret-abcdef")
6564                    .body(Body::empty())
6565                    .unwrap(),
6566            )
6567            .await
6568            .unwrap();
6569        // 1 seat + 2 outstanding >= cap 3 → refused even though only 1 real seat used.
6570        assert_eq!(resp.status(), StatusCode::CONFLICT);
6571    }
6572
6573    /// POST /bot/claims with a JSON body carrying the follower DID.
6574    async fn post_bot_claim_for(
6575        app: &axum::Router,
6576        secret: &str,
6577        did: &str,
6578    ) -> (StatusCode, serde_json::Value) {
6579        let resp = app
6580            .clone()
6581            .oneshot(
6582                Request::builder()
6583                    .method("POST")
6584                    .uri("/bot/claims")
6585                    .header("x-bot-secret", secret)
6586                    .header("content-type", "application/json")
6587                    .body(Body::from(format!(
6588                        "{{\"did\":\"{did}\",\"handle\":\"who.test\"}}"
6589                    )))
6590                    .unwrap(),
6591            )
6592            .await
6593            .unwrap();
6594        let status = resp.status();
6595        let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
6596            .await
6597            .unwrap();
6598        let json = if bytes.is_empty() {
6599            serde_json::Value::Null
6600        } else {
6601            serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null)
6602        };
6603        (status, json)
6604    }
6605
6606    #[tokio::test]
6607    async fn bot_claims_returns_already_seated_for_a_member() {
6608        // A DID that already holds beta access must get `already_seated` with NO
6609        // code/url — the bot posts nothing. This is the server-side backstop that
6610        // survives a bot-host state loss (it would otherwise re-mint + re-post).
6611        let state = bot_state("bot-secret-abcdef").await;
6612        store::grant_access(&state.db, "did:plc:member", None, "admin", None)
6613            .await
6614            .unwrap();
6615        let app = router(state.clone());
6616        let (status, json) = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:member").await;
6617        assert_eq!(status, StatusCode::OK);
6618        assert_eq!(json["status"], "already_seated");
6619        assert_eq!(json["code"], "");
6620        assert_eq!(json["url"], "");
6621        // No new invite code was minted for the seated DID.
6622        assert!(store::find_active_code_for_did(&state.db, "did:plc:member")
6623            .await
6624            .unwrap()
6625            .is_none());
6626    }
6627
6628    #[tokio::test]
6629    async fn bot_claims_is_idempotent_per_did_returns_same_code() {
6630        // Two mint requests for the SAME follower DID must return the SAME code
6631        // (the app is authoritative), never a second one — so a bot-host state loss
6632        // re-requesting cannot double-mint or double-post.
6633        let state = bot_state("bot-secret-abcdef").await;
6634        let app = router(state.clone());
6635
6636        let (s1, j1) = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:follower1").await;
6637        assert_eq!(s1, StatusCode::OK);
6638        assert_eq!(j1["status"], "minted");
6639        let code1 = j1["code"].as_str().unwrap().to_string();
6640
6641        let (s2, j2) = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:follower1").await;
6642        assert_eq!(s2, StatusCode::OK);
6643        assert_eq!(j2["status"], "existing");
6644        assert_eq!(j2["code"].as_str().unwrap(), code1, "same code returned");
6645        assert_eq!(j2["url"], j1["url"], "same url returned");
6646
6647        // Exactly ONE active code exists for that DID.
6648        assert_eq!(store::count_active_codes(&state.db).await.unwrap(), 1);
6649    }
6650
6651    #[tokio::test]
6652    async fn bot_claims_records_intended_did_at_mint() {
6653        // A fresh mint records the follower DID so the lookup finds it.
6654        let state = bot_state("bot-secret-abcdef").await;
6655        let app = router(state.clone());
6656        let (status, json) =
6657            post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:follower2").await;
6658        assert_eq!(status, StatusCode::OK);
6659        let code = json["code"].as_str().unwrap();
6660        assert_eq!(
6661            store::find_active_code_for_did(&state.db, "did:plc:follower2")
6662                .await
6663                .unwrap()
6664                .as_deref(),
6665            Some(code)
6666        );
6667    }
6668
6669    #[tokio::test]
6670    async fn bot_claims_concurrent_same_did_never_double_mints() {
6671        // S4: two concurrent /bot/claims for ONE follower DID must not both mint an
6672        // active code. The dedupe check (3b) and the mint are separate statements,
6673        // so a race can slip both past 3b's `Ok(None)`; the partial unique index
6674        // then makes the loser's INSERT conflict, and the handler recovers by
6675        // returning the winner's code (status `existing`) rather than 500-ing.
6676        // Result: exactly ONE active code, and BOTH callers get a usable code.
6677        let state = bot_state("bot-secret-abcdef").await;
6678        let app = router(state.clone());
6679
6680        let a = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:racer");
6681        let b = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:racer");
6682        let ((sa, ja), (sb, jb)) = tokio::join!(a, b);
6683
6684        assert_eq!(sa, StatusCode::OK, "first response: {ja:?}");
6685        assert_eq!(sb, StatusCode::OK, "second response: {jb:?}");
6686
6687        // Exactly one active code for the DID — the whole point of the fix.
6688        assert_eq!(
6689            store::count_active_codes(&state.db).await.unwrap(),
6690            1,
6691            "concurrent mints must not create two active codes"
6692        );
6693
6694        // Both callers received the SAME (single) code, and neither got a 500.
6695        let ca = ja["code"].as_str().unwrap_or("");
6696        let cb = jb["code"].as_str().unwrap_or("");
6697        assert!(!ca.is_empty() && !cb.is_empty(), "both must return a code");
6698        assert_eq!(ca, cb, "both callers must get the one minted code");
6699        // One is `minted` (the winner), the other `minted` or `existing` depending
6700        // on interleaving — but never an error status.
6701        for st in [&ja["status"], &jb["status"]] {
6702            let s = st.as_str().unwrap_or("");
6703            assert!(s == "minted" || s == "existing", "unexpected status {s:?}");
6704        }
6705    }
6706
6707    #[tokio::test]
6708    async fn bot_claims_rejects_malformed_json_body() {
6709        let state = bot_state("bot-secret-abcdef").await;
6710        let app = router(state);
6711        let resp = app
6712            .oneshot(
6713                Request::builder()
6714                    .method("POST")
6715                    .uri("/bot/claims")
6716                    .header("x-bot-secret", "bot-secret-abcdef")
6717                    .header("content-type", "application/json")
6718                    .body(Body::from("{not json"))
6719                    .unwrap(),
6720            )
6721            .await
6722            .unwrap();
6723        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
6724    }
6725
6726    #[tokio::test]
6727    async fn favicon_ico_served_at_root() {
6728        // Browsers request bare /favicon.ico regardless of the <link rel="icon">
6729        // tags in <head>; the root route must serve the icon, not 404.
6730        let state = test_state(&[]).await;
6731        let app = router(state);
6732        let resp = app
6733            .oneshot(
6734                Request::builder()
6735                    .uri("/favicon.ico")
6736                    .body(Body::empty())
6737                    .unwrap(),
6738            )
6739            .await
6740            .unwrap();
6741        assert_eq!(resp.status(), StatusCode::OK);
6742        let ct = resp
6743            .headers()
6744            .get(header::CONTENT_TYPE)
6745            .unwrap()
6746            .to_str()
6747            .unwrap();
6748        assert!(
6749            ct.contains("icon") || ct.starts_with("image/"),
6750            "content-type = {ct}"
6751        );
6752    }
6753
6754    #[tokio::test]
6755    async fn login_without_invite_redirects_to_beta_redeem() {
6756        // No allow-list seed, no invite cookie: starting OAuth must be refused.
6757        let state = test_state(&[]).await;
6758        let app = router(state);
6759        let resp = app
6760            .oneshot(
6761                Request::builder()
6762                    .method("POST")
6763                    .uri("/login")
6764                    .header("content-type", "application/x-www-form-urlencoded")
6765                    .body(Body::from("handle=alice.bsky.social"))
6766                    .unwrap(),
6767            )
6768            .await
6769            .unwrap();
6770        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
6771        assert_eq!(
6772            resp.headers().get(header::LOCATION).unwrap(),
6773            "/beta/redeem"
6774        );
6775    }
6776
6777    #[tokio::test]
6778    async fn login_with_valid_invite_cookie_starts_oauth() {
6779        let state = test_state(&[]).await;
6780        let cookie = sign_invite("FEATHER-ABCDWXYZ", &state.config.cookie_secret);
6781        let cookie = cookie.split(';').next().unwrap().to_string();
6782        let app = router(state);
6783        let resp = app
6784            .oneshot(
6785                Request::builder()
6786                    .method("POST")
6787                    .uri("/login")
6788                    .header("content-type", "application/x-www-form-urlencoded")
6789                    .header(header::COOKIE, cookie)
6790                    .body(Body::from("handle=alice.bsky.social"))
6791                    .unwrap(),
6792            )
6793            .await
6794            .unwrap();
6795        // Redirects into the sidecar login (not to /beta/redeem).
6796        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
6797        let loc = resp
6798            .headers()
6799            .get(header::LOCATION)
6800            .unwrap()
6801            .to_str()
6802            .unwrap();
6803        assert!(loc.contains("/login"), "loc = {loc}");
6804        assert_ne!(loc, "/beta/redeem");
6805    }
6806
6807    /// A resolver that always fails — proves the fast paths short-circuit BEFORE
6808    /// any network resolution and that a resolution failure fails closed.
6809    async fn resolver_never(_handle: String) -> Option<String> {
6810        None
6811    }
6812
6813    /// A resolver that maps every handle to `did`.
6814    fn resolver_to(did: &'static str) -> impl FnOnce(String) -> std::future::Ready<Option<String>> {
6815        move |_handle| std::future::ready(Some(did.to_string()))
6816    }
6817
6818    /// Path 3: a cookie-less visitor whose submitted handle resolves to a DID
6819    /// that already holds a seat (the seeded-admin first-login case) passes the
6820    /// gate — no session cookie, no invite code.
6821    #[tokio::test]
6822    async fn may_start_oauth_honors_seat_via_resolved_handle() {
6823        // The seeded admin holds a seat (via ALLOWED_DIDS → ensure_seed) but has
6824        // no cookie on a fresh deploy.
6825        let state = test_state(&["did:plc:admin"]).await;
6826        let headers = HeaderMap::new();
6827        assert!(
6828            may_start_oauth_with(
6829                &state,
6830                &headers,
6831                "admin.example",
6832                resolver_to("did:plc:admin")
6833            )
6834            .await,
6835            "a handle resolving to a seated DID must pass the gate"
6836        );
6837    }
6838
6839    /// Path 3, negative: a handle that resolves to a DID with NO seat is bounced
6840    /// — the anti-abuse intent is preserved (resolution succeeds, seat check
6841    /// fails).
6842    #[tokio::test]
6843    async fn may_start_oauth_bounces_non_member_handle() {
6844        let state = test_state(&["did:plc:admin"]).await;
6845        let headers = HeaderMap::new();
6846        assert!(
6847            !may_start_oauth_with(
6848                &state,
6849                &headers,
6850                "rando.example",
6851                resolver_to("did:plc:rando")
6852            )
6853            .await,
6854            "a resolved DID with no seat must be bounced"
6855        );
6856    }
6857
6858    /// Fail-closed: an unresolvable/malformed handle (resolver returns `None`)
6859    /// bounces gracefully — no panic, no handshake.
6860    #[tokio::test]
6861    async fn may_start_oauth_fails_closed_on_unresolvable_handle() {
6862        let state = test_state(&["did:plc:admin"]).await;
6863        let headers = HeaderMap::new();
6864        assert!(
6865            !may_start_oauth_with(&state, &headers, "not a handle", resolver_never).await,
6866            "an unresolvable handle must fail closed"
6867        );
6868    }
6869
6870    /// The session-cookie fast path admits a seated member WITHOUT calling the
6871    /// resolver (proven by injecting `resolver_never`, which would otherwise
6872    /// bounce).
6873    #[tokio::test]
6874    async fn may_start_oauth_session_cookie_shortcircuits_resolution() {
6875        let state = test_state(&[]).await;
6876        let did = "did:plc:member";
6877        store::grant_access(&state.db, did, Some("member.example"), "test", None)
6878            .await
6879            .unwrap();
6880        let cookie = session_cookie(&state, did, Some("member.example"));
6881        let mut headers = HeaderMap::new();
6882        headers.insert(header::COOKIE, cookie.parse().unwrap());
6883        assert!(
6884            may_start_oauth_with(&state, &headers, "member.example", resolver_never).await,
6885            "a seated session cookie must pass without resolution"
6886        );
6887    }
6888
6889    /// The invite-cookie fast path admits WITHOUT calling the resolver.
6890    #[tokio::test]
6891    async fn may_start_oauth_invite_cookie_shortcircuits_resolution() {
6892        let state = test_state(&[]).await;
6893        let cookie = sign_invite("FEATHER-ABCDWXYZ", &state.config.cookie_secret);
6894        let cookie = cookie.split(';').next().unwrap().to_string();
6895        let mut headers = HeaderMap::new();
6896        headers.insert(header::COOKIE, cookie.parse().unwrap());
6897        assert!(
6898            may_start_oauth_with(&state, &headers, "someone.example", resolver_never).await,
6899            "a valid invite cookie must pass without resolution"
6900        );
6901    }
6902
6903    #[tokio::test]
6904    async fn admin_mint_requires_admin_seed_did() {
6905        let state = test_state(&["did:plc:admin"]).await;
6906        // A non-admin (but beta'd) session is forbidden.
6907        store::grant_access(&state.db, "did:plc:rando", None, "test", None)
6908            .await
6909            .unwrap();
6910        let rando_cookie = session_cookie(&state, "did:plc:rando", None);
6911        // An admin session is allowed.
6912        let admin_cookie = session_cookie(&state, "did:plc:admin", None);
6913        let app = router(state);
6914
6915        let forbidden = app
6916            .clone()
6917            .oneshot(
6918                Request::builder()
6919                    .method("POST")
6920                    .uri("/admin/invites?n=2")
6921                    .header(header::COOKIE, rando_cookie)
6922                    .body(Body::empty())
6923                    .unwrap(),
6924            )
6925            .await
6926            .unwrap();
6927        assert_eq!(forbidden.status(), StatusCode::FORBIDDEN);
6928
6929        let ok = app
6930            .oneshot(
6931                Request::builder()
6932                    .method("POST")
6933                    .uri("/admin/invites?n=2")
6934                    .header(header::COOKIE, admin_cookie)
6935                    .body(Body::empty())
6936                    .unwrap(),
6937            )
6938            .await
6939            .unwrap();
6940        assert_eq!(ok.status(), StatusCode::OK);
6941        let bytes = axum::body::to_bytes(ok.into_body(), 64 * 1024)
6942            .await
6943            .unwrap();
6944        let body = String::from_utf8(bytes.to_vec()).unwrap();
6945        let minted: Vec<&str> = body.lines().filter(|l| !l.is_empty()).collect();
6946        assert_eq!(minted.len(), 2);
6947        assert!(minted.iter().all(|c| c.starts_with("FEATHER-")));
6948    }
6949
6950    #[tokio::test]
6951    async fn admin_mint_unauthenticated_is_401() {
6952        let state = test_state(&["did:plc:admin"]).await;
6953        let app = router(state);
6954        let resp = app
6955            .oneshot(
6956                Request::builder()
6957                    .method("POST")
6958                    .uri("/admin/invites")
6959                    .body(Body::empty())
6960                    .unwrap(),
6961            )
6962            .await
6963            .unwrap();
6964        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
6965    }
6966
6967    /// A state whose `/about` renders the adoption line, seeded with one
6968    /// observation.
6969    async fn adoption_state(repos: i64, truncated: bool) -> AppState {
6970        let db = store::init_url("sqlite::memory:").await.unwrap();
6971        store::record_network_stat(
6972            &db,
6973            &store::NetworkStat {
6974                key: store::ADOPTION_STAT_KEY.to_string(),
6975                source: "https://relay1.us-west.bsky.network".to_string(),
6976                value: repos,
6977                truncated,
6978                observed_at: "2026-08-13T04:05:06Z".to_string(),
6979            },
6980        )
6981        .await
6982        .unwrap();
6983        let config = Config {
6984            cookie_secret: "test-cookie-secret-000".to_string(),
6985            show_adoption: true,
6986            ..Config::default()
6987        };
6988        AppState::new(config, db).unwrap()
6989    }
6990
6991    async fn about_body(state: AppState) -> String {
6992        let resp = router(state)
6993            .oneshot(
6994                Request::builder()
6995                    .uri("/about")
6996                    .body(Body::empty())
6997                    .unwrap(),
6998            )
6999            .await
7000            .unwrap();
7001        assert_eq!(resp.status(), StatusCode::OK);
7002        let bytes = axum::body::to_bytes(resp.into_body(), 256 * 1024)
7003            .await
7004            .unwrap();
7005        String::from_utf8(bytes.to_vec()).unwrap()
7006    }
7007
7008    /// Default config ⇒ the flag is off ⇒ no line, and no query is issued.
7009    #[tokio::test]
7010    async fn about_omits_adoption_line_by_default() {
7011        let state = test_state(&[]).await;
7012        assert!(!state.config.show_adoption);
7013        let body = about_body(state).await;
7014        assert!(
7015            !body.contains("atproto network"),
7016            "the adoption line must not render by default"
7017        );
7018    }
7019
7020    #[tokio::test]
7021    async fn about_renders_adoption_line_when_enabled() {
7022        // **A distinctive count, and asserted IN ITS SENTENCE.**
7023        //
7024        // This used to seed 4 and assert `body.contains("4")`, which the
7025        // colophon's `width="44"` satisfies whatever the count is — so
7026        // hardcoding the rendered number passed. Both halves are needed: a
7027        // digit that does not occur incidentally, and the assertion tied to the
7028        // phrase it belongs to.
7029        let body = about_body(adoption_state(7_318, false).await).await;
7030        // The count and its phrase are on separate template lines, so compare
7031        // against a whitespace-collapsed copy rather than the raw HTML.
7032        let flat = body.split_whitespace().collect::<Vec<_>>().join(" ");
7033        assert!(
7034            flat.contains("7318 accounts on the atproto network hold"),
7035            "the count did not render in its own sentence: {flat}",
7036        );
7037        assert!(
7038            body.contains("accounts on the atproto network hold"),
7039            "{body}"
7040        );
7041        assert!(
7042            body.contains("2026-08-13"),
7043            "the observation date must render"
7044        );
7045        assert!(
7046            body.contains("lower bound"),
7047            "the non-archival caveat must ride along with the number"
7048        );
7049        assert!(
7050            !body.contains("At least"),
7051            "an untruncated count is exact-ish"
7052        );
7053    }
7054
7055    /// A count of one must read as "1 account … holds", not "1 accounts … hold".
7056    #[tokio::test]
7057    async fn about_adoption_line_is_singular_at_one() {
7058        let body = about_body(adoption_state(1, false).await).await;
7059        assert!(
7060            body.contains("account on the atproto network holds"),
7061            "{body}"
7062        );
7063    }
7064
7065    /// A truncated observation is a floor, and must say so.
7066    #[tokio::test]
7067    async fn about_adoption_line_says_at_least_when_truncated() {
7068        let body = about_body(adoption_state(25_000, true).await).await;
7069        assert!(body.contains("At least"), "{body}");
7070    }
7071
7072    /// Flag on but no observation (or a zero) ⇒ 200, no line, no error.
7073    #[tokio::test]
7074    async fn about_omits_line_when_enabled_with_no_observation() {
7075        let db = store::init_url("sqlite::memory:").await.unwrap();
7076        let config = Config {
7077            cookie_secret: "test-cookie-secret-000".to_string(),
7078            show_adoption: true,
7079            ..Config::default()
7080        };
7081        let body = about_body(AppState::new(config, db).unwrap()).await;
7082        assert!(!body.contains("atproto network"));
7083    }
7084
7085    // ---- standard.site on the public pages and the subscribe form ----------
7086    //
7087    // `FEATHERREADER_STANDARD_SITE` gates what may be STORED (`add_subscription`
7088    // refuses every `at://` paste with it off), so a page that tells the reader
7089    // to paste a publication URI is advertising a form that will be refused
7090    // unless the flag is on. These pin both halves: with the flag on the pages
7091    // say how; with it off they do not.
7092
7093    /// A state with the standard.site flag chosen, and `did` holding a seat so
7094    /// `/manage` renders for it.
7095    async fn standard_site_state(standard_site: bool, did: &str) -> AppState {
7096        let db = store::init_url("sqlite::memory:").await.unwrap();
7097        store::ensure_seed(&db, &[did.to_string()]).await.unwrap();
7098        let config = Config {
7099            allowed_dids: vec![did.to_string()],
7100            cookie_secret: "test-cookie-secret-000".to_string(),
7101            beta_cap: 3,
7102            standard_site,
7103            ..Config::default()
7104        };
7105        AppState::new(config, db).unwrap()
7106    }
7107
7108    /// `GET path` as `did`, asserted 200, body as a string.
7109    async fn signed_in_body(state: AppState, path: &str, did: &str) -> String {
7110        let cookie = session_cookie(&state, did, Some("reader.example"));
7111        let resp = router(state)
7112            .oneshot(
7113                Request::builder()
7114                    .uri(path)
7115                    .header(header::COOKIE, cookie)
7116                    .body(Body::empty())
7117                    .unwrap(),
7118            )
7119            .await
7120            .unwrap();
7121        assert_eq!(resp.status(), StatusCode::OK, "{path}");
7122        let bytes = axum::body::to_bytes(resp.into_body(), 512 * 1024)
7123            .await
7124            .unwrap();
7125        String::from_utf8(bytes.to_vec()).unwrap()
7126    }
7127
7128    /// `GET path` signed out, asserted 200, body as a string.
7129    async fn public_body(state: AppState, path: &str) -> String {
7130        let resp = router(state)
7131            .oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
7132            .await
7133            .unwrap();
7134        assert_eq!(resp.status(), StatusCode::OK, "{path}");
7135        let bytes = axum::body::to_bytes(resp.into_body(), 512 * 1024)
7136            .await
7137            .unwrap();
7138        String::from_utf8(bytes.to_vec()).unwrap()
7139    }
7140
7141    /// The `<input … id="feed-url" …>` tag of the subscribe form, whole.
7142    fn feed_url_input(body: &str) -> &str {
7143        let start = body
7144            .find("id=\"feed-url\"")
7145            .and_then(|i| body[..i].rfind("<input"))
7146            .expect("the subscribe form's URL input renders");
7147        let end = body[start..].find('>').expect("the input tag closes") + start + 1;
7148        &body[start..end]
7149    }
7150
7151    /// Flag on: the subscribe form says a publication URI is accepted, and shows
7152    /// both spellings the handler takes (DID and handle).
7153    #[tokio::test]
7154    async fn manage_hints_at_publications_when_the_flag_is_on() {
7155        let did = "did:plc:reader";
7156        let body = signed_in_body(standard_site_state(true, did).await, "/manage", did).await;
7157        assert!(
7158            body.contains("at://did:plc:…/site.standard.publication/…"),
7159            "the DID form must be shown: {body}"
7160        );
7161        assert!(
7162            body.contains("at://alice.example.com/site.standard.publication/…"),
7163            "the handle form must be shown: {body}"
7164        );
7165    }
7166
7167    /// Flag on: the URL input must not be `type="url"`. A browser validates
7168    /// that type with the WHATWG URL parser, which REJECTS the DID form —
7169    /// `at://did:plc:…/…` fails as an invalid port, the same failure
7170    /// `url::Url::parse` has (see `feed::is_storable_feed_url`) — so the form
7171    /// would refuse to submit the very string the hint asks for.
7172    #[tokio::test]
7173    async fn manage_url_input_accepts_a_did_uri_when_the_flag_is_on() {
7174        let did = "did:plc:reader";
7175        let body = signed_in_body(standard_site_state(true, did).await, "/manage", did).await;
7176        let input = feed_url_input(&body);
7177        assert!(
7178            input.contains("type=\"text\""),
7179            "the input must be type=text so a DID-form at:// URI can be submitted: {input}"
7180        );
7181        assert!(
7182            input.contains("inputmode=\"url\""),
7183            "the URL keyboard is still wanted: {input}"
7184        );
7185    }
7186
7187    /// Flag on, `type="text"` drops the browser's scheme check, so a pasted
7188    /// `example.com/blog` would reach the handler and come back as "Couldn't
7189    /// find a feed" — wrong, the site likely has one. A `pattern` keeps the
7190    /// browser asking for a scheme while still admitting `at://` (both cases:
7191    /// the handler canonicalises the scheme).
7192    #[tokio::test]
7193    async fn manage_url_input_still_requires_a_scheme_when_the_flag_is_on() {
7194        let did = "did:plc:reader";
7195        let body = signed_in_body(standard_site_state(true, did).await, "/manage", did).await;
7196        let input = feed_url_input(&body);
7197        assert!(
7198            input.contains(&format!("pattern=\"{FEED_URL_PATTERN}\"")),
7199            "the text input must keep a scheme check: {input}"
7200        );
7201    }
7202
7203    /// Flag off: every `at://` paste is refused, so the form must not say
7204    /// publications are accepted — and the input keeps browser URL validation.
7205    #[tokio::test]
7206    async fn manage_does_not_advertise_publications_when_the_flag_is_off() {
7207        let did = "did:plc:reader";
7208        let state = standard_site_state(false, did).await;
7209        assert!(!state.config.standard_site);
7210        let body = signed_in_body(state, "/manage", did).await;
7211        assert!(
7212            !body.contains("site.standard.publication"),
7213            "a refused form must not be advertised: {body}"
7214        );
7215        assert!(
7216            !body.contains("standard.site"),
7217            "a refused form must not be advertised: {body}"
7218        );
7219        assert!(
7220            feed_url_input(&body).contains("type=\"url\""),
7221            "with the flag off the input is unchanged"
7222        );
7223    }
7224
7225    /// Flag on: the landing page says publications sit beside feeds AND how to
7226    /// subscribe to one.
7227    #[tokio::test]
7228    async fn landing_describes_publications_and_how_to_subscribe_when_on() {
7229        let body = public_body(standard_site_state(true, "did:plc:x").await, "/").await;
7230        assert!(body.contains("standard.site"), "{body}");
7231        assert!(
7232            body.contains("at://did:plc:…/site.standard.publication/…"),
7233            "the landing page must show the DID form: {body}"
7234        );
7235        assert!(
7236            body.contains("at://alice.example.com/site.standard.publication/…"),
7237            "the landing page must show the handle form: {body}"
7238        );
7239    }
7240
7241    /// Flag off: the landing page still says what a publication is (a stored
7242    /// one is polled whatever the flag says), but shows no paste instructions
7243    /// and says new ones are not accepted here.
7244    #[tokio::test]
7245    async fn landing_does_not_tell_visitors_to_paste_a_publication_when_off() {
7246        let body = public_body(standard_site_state(false, "did:plc:x").await, "/").await;
7247        assert!(body.contains("standard.site"), "{body}");
7248        assert!(
7249            !body.contains("at://did:plc:…/site.standard.publication/…"),
7250            "no paste instructions with the flag off: {body}"
7251        );
7252        assert!(
7253            !body.contains("at://alice.example.com/site.standard.publication/…"),
7254            "no paste instructions with the flag off: {body}"
7255        );
7256        assert!(
7257            body.contains("isn't accepting new publication subscriptions"),
7258            "the page must say the form is closed here: {body}"
7259        );
7260    }
7261
7262    /// Flag on: /about has a publications section with both spellings.
7263    #[tokio::test]
7264    async fn about_describes_publications_and_how_to_subscribe_when_on() {
7265        let body = public_body(standard_site_state(true, "did:plc:x").await, "/about").await;
7266        assert!(body.contains("site.standard.publication"), "{body}");
7267        assert!(body.contains("site.standard.document"), "{body}");
7268        assert!(
7269            body.contains("at://did:plc:…/site.standard.publication/…"),
7270            "{body}"
7271        );
7272        assert!(
7273            body.contains("at://alice.example.com/site.standard.publication/…"),
7274            "{body}"
7275        );
7276    }
7277
7278    /// Flag off: /about keeps the description, drops the paste instructions.
7279    #[tokio::test]
7280    async fn about_does_not_tell_visitors_to_paste_a_publication_when_off() {
7281        let body = public_body(standard_site_state(false, "did:plc:x").await, "/about").await;
7282        assert!(body.contains("site.standard.publication"), "{body}");
7283        assert!(
7284            !body.contains("at://did:plc:…/site.standard.publication/…"),
7285            "no paste instructions with the flag off: {body}"
7286        );
7287        assert!(
7288            !body.contains("at://alice.example.com/site.standard.publication/…"),
7289            "no paste instructions with the flag off: {body}"
7290        );
7291        assert!(
7292            body.contains("isn't accepting new publication subscriptions"),
7293            "{body}"
7294        );
7295    }
7296
7297    #[tokio::test]
7298    async fn cache_control_public_on_about_no_store_on_authed() {
7299        let state = test_state(&["did:plc:admin"]).await;
7300        let admin_cookie = session_cookie(&state, "did:plc:admin", None);
7301        let app = router(state);
7302
7303        // /about → public, cacheable.
7304        let about = app
7305            .clone()
7306            .oneshot(
7307                Request::builder()
7308                    .uri("/about")
7309                    .body(Body::empty())
7310                    .unwrap(),
7311            )
7312            .await
7313            .unwrap();
7314        assert_eq!(
7315            about.headers().get(header::CACHE_CONTROL).unwrap(),
7316            "public, max-age=300"
7317        );
7318        // The security headers are still intact.
7319        // The VALUE, spelled out here rather than compared to the constant —
7320        // `== CONTENT_SECURITY_POLICY` passes with the constant gutted. This
7321        // used to assert only that the header existed, which a policy of
7322        // `default-src *` satisfies.
7323        assert_eq!(
7324            about.headers()["content-security-policy"],
7325            EXPECTED_CSP,
7326            "the CSP is not the policy the router promises"
7327        );
7328        assert_eq!(about.headers().get("x-frame-options").unwrap(), "DENY");
7329
7330        // /privacy and /terms are static public pages → public, cacheable.
7331        for path in ["/privacy", "/terms"] {
7332            let resp = app
7333                .clone()
7334                .oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
7335                .await
7336                .unwrap();
7337            assert_eq!(resp.status(), StatusCode::OK);
7338            assert_eq!(
7339                resp.headers().get(header::CACHE_CONTROL).unwrap(),
7340                "public, max-age=300",
7341                "{path} should be publicly cacheable"
7342            );
7343            // Security headers apply to these pages too.
7344            assert_eq!(resp.headers()["content-security-policy"], EXPECTED_CSP);
7345            assert_eq!(resp.headers().get("x-frame-options").unwrap(), "DENY");
7346        }
7347
7348        // The bare /login landing → public, cacheable.
7349        let login = app
7350            .clone()
7351            .oneshot(
7352                Request::builder()
7353                    .uri("/login")
7354                    .body(Body::empty())
7355                    .unwrap(),
7356            )
7357            .await
7358            .unwrap();
7359        assert_eq!(
7360            login.headers().get(header::CACHE_CONTROL).unwrap(),
7361            "public, max-age=300"
7362        );
7363
7364        // An authenticated page → no-store.
7365        let home = app
7366            .oneshot(
7367                Request::builder()
7368                    .uri("/")
7369                    .header(header::COOKIE, admin_cookie)
7370                    .body(Body::empty())
7371                    .unwrap(),
7372            )
7373            .await
7374            .unwrap();
7375        assert_eq!(
7376            home.headers().get(header::CACHE_CONTROL).unwrap(),
7377            "no-store"
7378        );
7379    }
7380
7381    #[tokio::test]
7382    async fn beta_redeem_page_renders() {
7383        let state = test_state(&[]).await;
7384        let app = router(state);
7385        let resp = app
7386            .oneshot(
7387                Request::builder()
7388                    .uri("/beta/redeem")
7389                    .body(Body::empty())
7390                    .unwrap(),
7391            )
7392            .await
7393            .unwrap();
7394        assert_eq!(resp.status(), StatusCode::OK);
7395        let bytes = axum::body::to_bytes(resp.into_body(), 256 * 1024)
7396            .await
7397            .unwrap();
7398        let html = String::from_utf8(bytes.to_vec()).unwrap();
7399        assert!(html.contains("Invite code"));
7400        assert!(html.contains("/beta/redeem"));
7401    }
7402
7403    #[tokio::test]
7404    async fn rate_limit_returns_429_after_burst() {
7405        // Configure a trusted proxy header so the limiter keys on the forwarded
7406        // IP (the oneshot harness sets no ConnectInfo socket peer).
7407        let db = store::init_url("sqlite::memory:").await.unwrap();
7408        store::ensure_seed(&db, &[]).await.unwrap();
7409        let config = Config {
7410            cookie_secret: "test-cookie-secret-000".to_string(),
7411            beta_cap: 3,
7412            trusted_ip_header: Some("cf-connecting-ip".to_string()),
7413            ..Config::default()
7414        };
7415        let state = AppState::new(config, db).unwrap();
7416        let app = router(state);
7417        // Hammer POST /beta/redeem past the burst from a single (trusted) IP. The
7418        // handler itself returns 200 (re-render) on a bad code; the limiter is
7419        // what eventually yields 429.
7420        let mut saw_429 = false;
7421        for _ in 0..(RATE_BURST as usize + 5) {
7422            let resp = app
7423                .clone()
7424                .oneshot(
7425                    Request::builder()
7426                        .method("POST")
7427                        .uri("/beta/redeem")
7428                        .header("content-type", "application/x-www-form-urlencoded")
7429                        .header("cf-connecting-ip", "203.0.113.200")
7430                        .body(Body::from("code=FEATHER-NOPENOPE"))
7431                        .unwrap(),
7432                )
7433                .await
7434                .unwrap();
7435            if resp.status() == StatusCode::TOO_MANY_REQUESTS {
7436                saw_429 = true;
7437                break;
7438            }
7439        }
7440        assert!(saw_429, "expected a 429 after exhausting the burst");
7441    }
7442
7443    /// **A forged `X-Forwarded-For` does not key the limiter** — the property
7444    /// the middleware's comment cites this test as proof of.
7445    ///
7446    /// The previous version rotated the forged header and asserted that no
7447    /// request was EVER 429'd. Rotating addresses can never exhaust a per-IP
7448    /// burst, so that assertion held whether the header was trusted or
7449    /// ignored — it passed in the vulnerable configuration too. And with no
7450    /// socket peer the limiter fails open, so nothing could have been keyed on
7451    /// anything. Now one real peer sends `RATE_BURST + 5` requests, each with
7452    /// a DIFFERENT forged header, and the last must be 429: they all landed in
7453    /// the peer's bucket. A limiter keying on the header mints a fresh bucket
7454    /// per request and never trips — which is exactly what the mutation does.
7455    #[tokio::test]
7456    async fn a_forged_forwarded_for_header_does_not_key_the_limiter() {
7457        let state = test_state(&[]).await;
7458        assert!(
7459            state.config.trusted_ip_header.is_none(),
7460            "no proxy header is trusted here"
7461        );
7462        let app = router(state);
7463        let peer = std::net::SocketAddr::from(([203, 0, 113, 7], 40000));
7464        let mut saw_429 = false;
7465        for i in 0..(RATE_BURST as usize + 5) {
7466            let forged = format!("10.9.8.{}", i % 250);
7467            let resp = app
7468                .clone()
7469                .oneshot(
7470                    Request::builder()
7471                        .method("POST")
7472                        .uri("/beta/redeem")
7473                        .header("content-type", "application/x-www-form-urlencoded")
7474                        .header("x-forwarded-for", forged)
7475                        .extension(axum::extract::ConnectInfo(peer))
7476                        .body(Body::from("code=FEATHER-NOPENOPE"))
7477                        .unwrap(),
7478                )
7479                .await
7480                .unwrap();
7481            if resp.status() == StatusCode::TOO_MANY_REQUESTS {
7482                saw_429 = true;
7483                break;
7484            }
7485        }
7486        assert!(
7487            saw_429,
7488            "rotating a forged X-Forwarded-For minted fresh buckets: the limiter is keyed on an attacker-chosen header"
7489        );
7490    }
7491
7492    // -- OPML import body cap (DefaultBodyLimit → 413) -------------------------
7493
7494    /// **A private feed is refused BEFORE it is fetched.** The add path's
7495    /// privacy gate had no test at all — `private_feeds_are_classified_private_
7496    /// across_providers` says "the add + OPML paths both gate on this
7497    /// classifier" and nothing checked either. The gate exists so a
7498    /// token-bearing URL never reaches the network; the assertion that
7499    /// matters is the server's hit count: zero.
7500    #[tokio::test]
7501    async fn subscribing_to_a_private_feed_never_reaches_the_network() {
7502        let did = "did:plc:privateadder";
7503        let state = test_state_with_caps(did, 0, 0).await;
7504        let (base, hits) = crate::net::tests::serve_body_counted(b"<rss/>".to_vec()).await;
7505        let port: u16 = base
7506            .trim_end_matches('/')
7507            .rsplit(':')
7508            .next()
7509            .unwrap()
7510            .parse()
7511            .unwrap();
7512        crate::net::test_host_override(
7513            "private-add.test",
7514            std::net::SocketAddr::from(([127, 0, 0, 1], port)),
7515        );
7516        let cookie = session_cookie(&state, did, None);
7517        let resp = router(state.clone())
7518            .oneshot(
7519                Request::builder()
7520                    .method("POST")
7521                    .uri("/subscriptions")
7522                    .header(header::COOKIE, cookie)
7523                    .header("content-type", "application/x-www-form-urlencoded")
7524                    .body(Body::from(format!(
7525                        "url=http%3A%2F%2Fprivate-add.test%3A{port}%2Ffeed%2Fprivate%2Fdeadbeefcafe1234"
7526                    )))
7527                    .unwrap(),
7528            )
7529            .await
7530            .unwrap();
7531        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
7532        let loc = resp
7533            .headers()
7534            .get(header::LOCATION)
7535            .unwrap()
7536            .to_str()
7537            .unwrap();
7538        assert!(loc.contains("Private"), "not refused as private: {loc}");
7539        assert_eq!(
7540            hits.load(std::sync::atomic::Ordering::SeqCst),
7541            0,
7542            "the private feed was FETCHED before being refused"
7543        );
7544        assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
7545    }
7546
7547    /// **OPML import skips a private feed without storing or publishing it.**
7548    /// The import path does not fetch, so "never fetched" is not the signal
7549    /// here; "never stored, never written to the PDS" is. The batch write's
7550    /// bytes are captured and must not carry the URL.
7551    #[tokio::test]
7552    async fn opml_import_skips_a_private_feed_without_storing_or_publishing_it() {
7553        let did = "did:plc:renamer4";
7554        let (sidecar, bodies) = spawn_logging_sidecar().await;
7555        let state = test_state_with_sidecar(&[did], &sidecar).await;
7556        let tokened = "https://www.patreon.com/rss/author?auth=Zm9vYmFyc2VjcmV0dG9rZW4";
7557        let opml = format!(
7558            "<?xml version=\"1.0\"?>\n<opml version=\"2.0\"><head><title>t</title></head><body>\n\
7559             <outline type=\"rss\" text=\"Public\" xmlUrl=\"https://public.example/feed.xml\"/>\n\
7560             <outline type=\"rss\" text=\"Paid\" xmlUrl=\"{tokened}\"/>\n\
7561             </body></opml>"
7562        );
7563        let (ct, body) = opml_multipart(opml.as_bytes());
7564        let cookie = session_cookie(&state, did, None);
7565        let resp = router(state.clone())
7566            .oneshot(
7567                Request::builder()
7568                    .method("POST")
7569                    .uri("/opml")
7570                    .header(header::COOKIE, cookie)
7571                    .header("content-type", ct)
7572                    .body(Body::from(body))
7573                    .unwrap(),
7574            )
7575            .await
7576            .unwrap();
7577        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
7578        let loc = resp
7579            .headers()
7580            .get(header::LOCATION)
7581            .unwrap()
7582            .to_str()
7583            .unwrap();
7584        assert!(
7585            loc.contains("skipped%20as%20private"),
7586            "not reported as skipped: {loc}"
7587        );
7588        assert!(store::get_feed_by_url(&state.db, tokened)
7589            .await
7590            .unwrap()
7591            .is_none());
7592        let sent = bodies.lock().unwrap().join("\n");
7593        assert!(
7594            sent.contains("public.example"),
7595            "the public feed was not written: {sent}"
7596        );
7597        assert!(
7598            !sent.contains("Zm9vYmFyc2VjcmV0dG9rZW4"),
7599            "the secret was PUBLISHED to the PDS: {sent}"
7600        );
7601    }
7602
7603    /// **`GET /login?handle=` is gated like `POST /login`.** Only the POST was
7604    /// tested; the GET form starts the same handshake and had no test, so
7605    /// deleting its gate left the suite green.
7606    #[tokio::test]
7607    async fn get_login_without_a_seat_is_refused() {
7608        let state = test_state(&[]).await;
7609        let resp = router(state)
7610            .oneshot(
7611                Request::builder()
7612                    .method("GET")
7613                    .uri("/login?handle=alice.bsky.social")
7614                    .body(Body::empty())
7615                    .unwrap(),
7616            )
7617            .await
7618            .unwrap();
7619        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
7620        assert_eq!(
7621            resp.headers().get(header::LOCATION).unwrap(),
7622            "/beta/redeem"
7623        );
7624    }
7625
7626    /// A sidecar fake that answers every request `ok` and records the PATH of
7627    /// each in arrival order, plus every body — for asserting what was sent,
7628    /// and in what order.
7629    async fn spawn_logging_sidecar() -> (String, std::sync::Arc<std::sync::Mutex<Vec<String>>>) {
7630        use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
7631        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7632        let addr = listener.local_addr().unwrap();
7633        let log = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
7634        let sink = log.clone();
7635        tokio::spawn(async move {
7636            loop {
7637                let Ok((mut sock, _)) = listener.accept().await else {
7638                    break;
7639                };
7640                let mut raw: Vec<u8> = Vec::new();
7641                let mut chunk = [0u8; 4096];
7642                let text = loop {
7643                    let Ok(n) = sock.read(&mut chunk).await else {
7644                        break String::new();
7645                    };
7646                    if n == 0 {
7647                        break String::from_utf8_lossy(&raw).to_string();
7648                    }
7649                    raw.extend_from_slice(&chunk[..n]);
7650                    let Some(split) = raw.windows(4).position(|w| w == b"\r\n\r\n") else {
7651                        continue;
7652                    };
7653                    let (head, body) = raw.split_at(split + 4);
7654                    let want = String::from_utf8_lossy(head).lines().find_map(|l| {
7655                        let (k, v) = l.split_once(':')?;
7656                        k.eq_ignore_ascii_case("content-length")
7657                            .then(|| v.trim().parse::<usize>().ok())?
7658                    });
7659                    if want.is_none_or(|w| body.len() >= w) {
7660                        break String::from_utf8_lossy(&raw).to_string();
7661                    }
7662                };
7663                let path = text
7664                    .lines()
7665                    .next()
7666                    .and_then(|l| l.split_whitespace().nth(1))
7667                    .unwrap_or("")
7668                    .to_string();
7669                let body_text = text
7670                    .split_once("\r\n\r\n")
7671                    .map(|(_, b)| b)
7672                    .unwrap_or("")
7673                    .to_string();
7674                sink.lock().unwrap().push(format!("{path} {body_text}"));
7675                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();
7676                let resp = format!(
7677                    "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
7678                    body.len(),
7679                    body
7680                );
7681                let _ = sock.write_all(resp.as_bytes()).await;
7682                let _ = sock.flush().await;
7683            }
7684        });
7685        (format!("http://{addr}"), log)
7686    }
7687
7688    /// **Sign-out flushes dirty read-state BEFORE it revokes — through the
7689    /// route.** The previous version of this test called
7690    /// `flush_before_revoke` and `revoke_everywhere` itself and asserted one
7691    /// flush attempt; its doc claimed deleting the call from the handler
7692    /// "drops that to zero", which was false — the handler was never run.
7693    /// Deleting the call left the suite green: #117 regressing in full, with
7694    /// the test named after it still passing. Now `POST /logout` is driven and
7695    /// the sidecar's log must show a repo write BEFORE the revoke.
7696    #[tokio::test]
7697    async fn signing_out_flushes_before_it_revokes_through_the_route() {
7698        let did = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
7699        let (sidecar, log) = spawn_logging_sidecar().await;
7700        let state = test_state_with_sidecar(&[did], &sidecar).await;
7701        crate::store::upsert_cursor(
7702            &state.db,
7703            &crate::store::ReadCursor {
7704                did: did.to_string(),
7705                feed_url: "https://example.com/feed.xml".into(),
7706                read_through: None,
7707                read_ids: "[\"1\"]".into(),
7708                unread_ids: "[]".into(),
7709                dirty: true,
7710                pds_created: false,
7711                updated_at: "2026-09-13T21:22:40Z".into(),
7712            },
7713        )
7714        .await
7715        .unwrap();
7716        let cookie = session_cookie(&state, did, None);
7717        let resp = router(state.clone())
7718            .oneshot(
7719                Request::builder()
7720                    .method("POST")
7721                    .uri("/logout")
7722                    .header(header::COOKIE, cookie)
7723                    .body(Body::empty())
7724                    .unwrap(),
7725            )
7726            .await
7727            .unwrap();
7728        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
7729
7730        let entries = log.lock().unwrap().clone();
7731        let flush = entries
7732            .iter()
7733            .position(|e| e.starts_with("/internal/repo "));
7734        let revoke = entries
7735            .iter()
7736            .position(|e| e.starts_with("/internal/revoke "));
7737        assert!(revoke.is_some(), "sign-out did not revoke: {entries:?}");
7738        assert!(
7739            flush.is_some(),
7740            "sign-out did not attempt a flush before revoking: {entries:?}"
7741        );
7742        assert!(
7743            flush < revoke,
7744            "the flush arrived AFTER the revoke — no session left to send it with: {entries:?}"
7745        );
7746    }
7747
7748    /// The policy, as a literal: the backstop the router calls "neutralises any
7749    /// XSS that slips past sanitization". `script-src 'self'` and no
7750    /// `'unsafe-inline'` on it are the two clauses that make it one.
7751    const EXPECTED_CSP: &str = "default-src 'self'; \
7752     script-src 'self'; \
7753     style-src 'self' 'unsafe-inline'; \
7754     img-src 'self' https: data:; \
7755     font-src 'self'; \
7756     connect-src 'self'; \
7757     form-action 'self'; \
7758     base-uri 'self'; \
7759     frame-ancestors 'none'; \
7760     object-src 'none'";
7761
7762    /// Build a `multipart/form-data` body carrying a single `file` field whose
7763    /// contents are `payload`, returning `(content_type, body_bytes)`.
7764    fn opml_multipart(payload: &[u8]) -> (String, Vec<u8>) {
7765        let boundary = "----featherreadertestboundary";
7766        let mut body = Vec::new();
7767        body.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
7768        body.extend_from_slice(
7769            b"Content-Disposition: form-data; name=\"file\"; filename=\"feeds.opml\"\r\n",
7770        );
7771        body.extend_from_slice(b"Content-Type: text/x-opml\r\n\r\n");
7772        body.extend_from_slice(payload);
7773        body.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes());
7774        (format!("multipart/form-data; boundary={boundary}"), body)
7775    }
7776
7777    #[tokio::test]
7778    async fn opml_import_oversize_upload_returns_413() {
7779        let state = test_state(&["did:plc:admin"]).await;
7780        let cookie = session_cookie(&state, "did:plc:admin", None);
7781        let app = router(state);
7782
7783        // A payload comfortably above the route cap.
7784        let payload = vec![b'a'; OPML_BODY_LIMIT + 1024];
7785        let (content_type, body) = opml_multipart(&payload);
7786
7787        let resp = app
7788            .oneshot(
7789                Request::builder()
7790                    .method("POST")
7791                    .uri("/opml")
7792                    .header("content-type", content_type)
7793                    .header(header::COOKIE, cookie)
7794                    .body(Body::from(body))
7795                    .unwrap(),
7796            )
7797            .await
7798            .unwrap();
7799        assert_eq!(
7800            resp.status(),
7801            StatusCode::PAYLOAD_TOO_LARGE,
7802            "an over-cap OPML upload must be rejected with 413, not collapsed to 500"
7803        );
7804    }
7805
7806    /// **The route's own cap is what refuses this, not the framework's.**
7807    ///
7808    /// `OPML_BODY_LIMIT` used to equal axum's `DefaultBodyLimit` (2 MiB), so
7809    /// the route's layer was a no-op — deleting it left every test green, and
7810    /// `opml_import_oversize_upload_returns_413` was really testing axum. The
7811    /// limit is 1 MiB now, strictly tighter, and this uploads a payload that
7812    /// sits BETWEEN the two: over ours, under the framework's. Only the
7813    /// route's layer can refuse it — remove the layer and this payload is
7814    /// accepted, which is also what demonstrates the framework's default is
7815    /// the larger of the two.
7816    #[tokio::test]
7817    async fn opml_import_over_the_route_cap_is_refused_below_the_framework_default() {
7818        let state = test_state(&["did:plc:admin"]).await;
7819        let cookie = session_cookie(&state, "did:plc:admin", None);
7820        let app = router(state);
7821
7822        // Between the two ceilings: the framework would accept this.
7823        let payload = vec![b'a'; (OPML_BODY_LIMIT + AXUM_DEFAULT_BODY_LIMIT) / 2];
7824        let (content_type, body) = opml_multipart(&payload);
7825
7826        let resp = app
7827            .oneshot(
7828                Request::builder()
7829                    .method("POST")
7830                    .uri("/opml")
7831                    .header("content-type", content_type)
7832                    .header(header::COOKIE, cookie)
7833                    .body(Body::from(body))
7834                    .unwrap(),
7835            )
7836            .await
7837            .unwrap();
7838        assert_eq!(
7839            resp.status(),
7840            StatusCode::PAYLOAD_TOO_LARGE,
7841            "a payload over the route's cap but under the framework's was accepted — \
7842             the route's own DefaultBodyLimit layer is not doing anything"
7843        );
7844    }
7845
7846    #[tokio::test]
7847    async fn opml_import_under_limit_upload_is_accepted() {
7848        let state = test_state(&["did:plc:admin"]).await;
7849        let cookie = session_cookie(&state, "did:plc:admin", None);
7850        let db = state.db.clone();
7851        let app = router(state);
7852
7853        // A small, valid OPML well under the cap: must be accepted (the handler
7854        // redirects to `/` or a flash), i.e. never 413.
7855        let opml = br#"<?xml version="1.0"?>
7856<opml version="2.0"><body>
7857  <outline text="Example" type="rss" xmlUrl="https://example.com/feed.xml"/>
7858</body></opml>"#;
7859        let (content_type, body) = opml_multipart(opml);
7860
7861        let resp = app
7862            .oneshot(
7863                Request::builder()
7864                    .method("POST")
7865                    .uri("/opml")
7866                    .header("content-type", content_type)
7867                    .header(header::COOKIE, cookie)
7868                    .body(Body::from(body))
7869                    .unwrap(),
7870            )
7871            .await
7872            .unwrap();
7873        // **Assert it was ACCEPTED, not merely that it was not a 413.**
7874        //
7875        // The old assertion was `assert_ne!(status, PAYLOAD_TOO_LARGE)`, which a
7876        // 500 satisfies — so making `import_opml` fail unconditionally left this
7877        // green. Three other OPML tests caught that mutation; the one whose name
7878        // promises to cover the under-cap case did not.
7879        assert_eq!(
7880            resp.status(),
7881            StatusCode::SEE_OTHER,
7882            "an under-cap OPML upload was not accepted (status {})",
7883            resp.status(),
7884        );
7885        // **303 alone is not acceptance.** `import_opml` redirects on several
7886        // FAILURES too — unparseable OPML, zero feeds found, every feed trimmed
7887        // by a cap — so an import that stored nothing satisfied the status check.
7888        let stored: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM feeds WHERE url = ?1")
7889            .bind("https://example.com/feed.xml")
7890            .fetch_one(&db)
7891            .await
7892            .unwrap();
7893        assert_eq!(stored, 1, "the upload was redirected but imported nothing");
7894        let location = resp
7895            .headers()
7896            .get(header::LOCATION)
7897            .and_then(|v| v.to_str().ok())
7898            .unwrap_or_default()
7899            .to_string();
7900        assert!(
7901            !location.starts_with("/login"),
7902            "the import bounced to login instead of being accepted: {location}",
7903        );
7904    }
7905
7906    #[tokio::test]
7907    async fn opml_import_logged_out_redirects_to_login() {
7908        // Logged-out callers are redirected before the body is consumed; assert
7909        // the auth short-circuit rather than a body-cap rejection.
7910        let state = test_state(&["did:plc:admin"]).await;
7911        let app = router(state);
7912
7913        let opml = b"<opml version=\"2.0\"><body></body></opml>";
7914        let (content_type, body) = opml_multipart(opml);
7915
7916        let resp = app
7917            .oneshot(
7918                Request::builder()
7919                    .method("POST")
7920                    .uri("/opml")
7921                    .header("content-type", content_type)
7922                    .body(Body::from(body))
7923                    .unwrap(),
7924            )
7925            .await
7926            .unwrap();
7927        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
7928        assert_eq!(resp.headers().get(header::LOCATION).unwrap(), "/login");
7929    }
7930
7931    // -- delete-my-data (POST /account/delete) --------------------------------
7932
7933    /// A one-shot mock sidecar: binds a loopback port, answers exactly one
7934    /// `POST /internal/revoke` with `{ok:true,…}`, and reports (via the returned
7935    /// channel) the DID it was asked to revoke. Enough to prove the delete
7936    /// handler triggers the sidecar revoke without pulling in an HTTP-mock crate.
7937    async fn spawn_revoke_sidecar() -> (String, tokio::sync::oneshot::Receiver<String>) {
7938        use tokio::io::{AsyncReadExt, AsyncWriteExt};
7939        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7940        let addr = listener.local_addr().unwrap();
7941        let (tx, rx) = tokio::sync::oneshot::channel::<String>();
7942        tokio::spawn(async move {
7943            let (mut sock, _) = listener.accept().await.unwrap();
7944            let mut buf = vec![0u8; 4096];
7945            let n = sock.read(&mut buf).await.unwrap();
7946            let req = String::from_utf8_lossy(&buf[..n]).to_string();
7947            // Pull the DID out of the JSON body (last line of the request).
7948            let did = req
7949                .split("\r\n\r\n")
7950                .nth(1)
7951                .and_then(|body| {
7952                    let v: serde_json::Value = serde_json::from_str(body.trim()).ok()?;
7953                    v.get("did")?.as_str().map(str::to_string)
7954                })
7955                .unwrap_or_default();
7956            let is_revoke = req.starts_with("POST /internal/revoke");
7957            let body = serde_json::json!({
7958                "ok": true, "did": did, "revoked": true, "hadSession": true
7959            })
7960            .to_string();
7961            let resp = format!(
7962                "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
7963                body.len(),
7964                body
7965            );
7966            sock.write_all(resp.as_bytes()).await.unwrap();
7967            sock.flush().await.unwrap();
7968            let _ = tx.send(if is_revoke { did } else { String::new() });
7969        });
7970        (format!("http://{addr}"), rx)
7971    }
7972
7973    /// Build an [`AppState`] whose sidecar points at `sidecar_url`.
7974    async fn test_state_with_sidecar(allowed: &[&str], sidecar_url: &str) -> AppState {
7975        let defaults = Config::default();
7976        test_state_with_sidecar_and(
7977            allowed,
7978            sidecar_url,
7979            defaults.standard_site,
7980            defaults.max_feeds_global,
7981        )
7982        .await
7983    }
7984
7985    /// [`test_state_with_sidecar`] with the standard.site flag and the global
7986    /// feeds ceiling chosen — the two settings the at:// paths branch on.
7987    async fn test_state_with_sidecar_and(
7988        allowed: &[&str],
7989        sidecar_url: &str,
7990        standard_site: bool,
7991        max_feeds_global: i64,
7992    ) -> AppState {
7993        let db = store::init_url("sqlite::memory:").await.unwrap();
7994        let dids: Vec<String> = allowed.iter().map(|s| s.to_string()).collect();
7995        store::ensure_seed(&db, &dids).await.unwrap();
7996        let mut config = Config {
7997            allowed_dids: dids,
7998            cookie_secret: "test-cookie-secret-000".to_string(),
7999            beta_cap: 3,
8000            standard_site,
8001            max_feeds_global,
8002            ..Config::default()
8003        };
8004        config.sidecar.public_url = sidecar_url.to_string();
8005        config.sidecar.internal_url = sidecar_url.to_string();
8006        AppState::new(config, db).unwrap()
8007    }
8008
8009    /// A confirmed `POST /account/delete` purges the caller's local rows, calls
8010    /// the sidecar revoke for that DID, and clears the session cookie.
8011    #[tokio::test]
8012    async fn account_delete_purges_rows_and_triggers_revoke() {
8013        let (sidecar_url, revoke_rx) = spawn_revoke_sidecar().await;
8014        let did = "did:plc:leaver";
8015        let state = test_state_with_sidecar(&[], &sidecar_url).await;
8016
8017        // Seed the DID with local rows across the per-DID tables.
8018        store::grant_access(&state.db, did, Some("leaver.example"), "test", None)
8019            .await
8020            .unwrap();
8021        store::replace_sub_refs(&state.db, did, &[]).await.unwrap();
8022        store::mint_code(&state.db, did, 3600).await.unwrap();
8023        assert!(store::has_beta_access(&state.db, did).await.unwrap());
8024
8025        let cookie = session_cookie(&state, did, Some("leaver.example"));
8026        let app = router(state.clone());
8027
8028        let resp = app
8029            .oneshot(
8030                Request::builder()
8031                    .method("POST")
8032                    .uri("/account/delete")
8033                    .header(header::COOKIE, cookie)
8034                    .header("content-type", "application/x-www-form-urlencoded")
8035                    .body(Body::from("confirm=DELETE"))
8036                    .unwrap(),
8037            )
8038            .await
8039            .unwrap();
8040
8041        // Signed out: redirect to /login with the cookie cleared.
8042        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8043        assert!(resp
8044            .headers()
8045            .get(header::LOCATION)
8046            .unwrap()
8047            .to_str()
8048            .unwrap()
8049            .starts_with("/login"));
8050        let set_cookie = resp
8051            .headers()
8052            .get(header::SET_COOKIE)
8053            .unwrap()
8054            .to_str()
8055            .unwrap();
8056        assert!(set_cookie.contains("Max-Age=0"), "cookie must be cleared");
8057
8058        // The sidecar revoke was called for exactly this DID.
8059        //
8060        // BOUNDED. A bare `await` here meant a broken `revoke_everywhere` — one
8061        // that simply never called the sidecar — hung this test forever instead
8062        // of failing it: a wedged CI job rather than a red one, which is the
8063        // worse of the two signals because nobody reads it as a defect.
8064        let revoked_did = tokio::time::timeout(std::time::Duration::from_secs(10), revoke_rx)
8065            .await
8066            .expect("the sidecar revoke never fired; revoke_everywhere did not call it")
8067            .unwrap();
8068        assert_eq!(
8069            revoked_did, did,
8070            "sidecar revoke must fire for the caller DID"
8071        );
8072
8073        // Local rows are gone.
8074        assert!(!store::has_beta_access(&state.db, did).await.unwrap());
8075        let codes: i64 =
8076            sqlx::query_scalar("SELECT COUNT(*) FROM invite_codes WHERE creator_did = ?1")
8077                .bind(did)
8078                .fetch_one(&state.db)
8079                .await
8080                .unwrap();
8081        assert_eq!(codes, 0);
8082    }
8083
8084    /// An UN-confirmed `POST /account/delete` (wrong/blank `confirm`) deletes
8085    /// nothing and bounces back to /manage.
8086    #[tokio::test]
8087    async fn account_delete_without_confirm_is_a_noop() {
8088        let did = "did:plc:staying";
8089        let state = test_state(&[]).await;
8090        store::grant_access(&state.db, did, None, "test", None)
8091            .await
8092            .unwrap();
8093        let cookie = session_cookie(&state, did, None);
8094        let app = router(state.clone());
8095
8096        let resp = app
8097            .oneshot(
8098                Request::builder()
8099                    .method("POST")
8100                    .uri("/account/delete")
8101                    .header(header::COOKIE, cookie)
8102                    .header("content-type", "application/x-www-form-urlencoded")
8103                    .body(Body::from("confirm=nope"))
8104                    .unwrap(),
8105            )
8106            .await
8107            .unwrap();
8108
8109        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8110        assert!(resp
8111            .headers()
8112            .get(header::LOCATION)
8113            .unwrap()
8114            .to_str()
8115            .unwrap()
8116            .starts_with("/manage"));
8117        // Nothing deleted.
8118        assert!(store::has_beta_access(&state.db, did).await.unwrap());
8119    }
8120
8121    /// PDS-outage authorization: when the sidecar is unreachable (as it is in
8122    /// this harness — the default sidecar URL is not served), a DID must STILL
8123    /// be unable to read or mutate an entry in a feed it does not subscribe to.
8124    /// This guards the `resolve_subscriptions` fallback: it must fail CLOSED
8125    /// (serve only the DID's own `sub_ref`), never widen the caller's surface to
8126    /// every cached feed.
8127    #[tokio::test]
8128    async fn pds_outage_does_not_widen_cross_did_access() {
8129        let did_a = "did:plc:aaaa";
8130        let state = test_state(&[]).await;
8131        store::grant_access(&state.db, did_a, None, "test", None)
8132            .await
8133            .unwrap();
8134
8135        // Shared cache: feed_a (A subscribes) + feed_b (A does NOT). An entry
8136        // lives in feed_b — the one A must never touch during the outage.
8137        let feed_a = store::upsert_feed(
8138            &state.db,
8139            &store::NewFeed {
8140                url: "https://a.example/feed.xml".to_string(),
8141                title: Some("A".to_string()),
8142                ..Default::default()
8143            },
8144        )
8145        .await
8146        .unwrap();
8147        let feed_b = store::upsert_feed(
8148            &state.db,
8149            &store::NewFeed {
8150                url: "https://b.example/feed.xml".to_string(),
8151                title: Some("B".to_string()),
8152                ..Default::default()
8153            },
8154        )
8155        .await
8156        .unwrap();
8157        store::insert_entries(
8158            &state.db,
8159            feed_b,
8160            &[store::NewEntry {
8161                guid: "b-1".to_string(),
8162                url: Some("https://b.example/1".to_string()),
8163                title: Some("B one".to_string()),
8164                published: Some("2026-07-11T00:00:00Z".to_string()),
8165                content_html: Some("<p>secret B body</p>".to_string()),
8166                ..Default::default()
8167            }],
8168            0,
8169        )
8170        .await
8171        .unwrap();
8172        // A subscribes ONLY to feed_a.
8173        store::replace_sub_refs(&state.db, did_a, &[feed_a])
8174            .await
8175            .unwrap();
8176        // Read B's entry id via a transient sub_ref, then drop it so only the
8177        // shared cache holds B's entry (no DID subscribes to feed_b anymore).
8178        store::replace_sub_refs(&state.db, "did:plc:bbbb", &[feed_b])
8179            .await
8180            .unwrap();
8181        let b_entry_id = store::entries_for_feed(&state.db, "did:plc:bbbb", feed_b)
8182            .await
8183            .unwrap()[0]
8184            .id;
8185        store::replace_sub_refs(&state.db, "did:plc:bbbb", &[])
8186            .await
8187            .unwrap();
8188
8189        let cookie = session_cookie(&state, did_a, None);
8190        let app = router(state.clone());
8191
8192        // GET /entries/{b} as A → 404 even during the outage.
8193        let get_b = app
8194            .clone()
8195            .oneshot(
8196                Request::builder()
8197                    .method("GET")
8198                    .uri(format!("/entries/{b_entry_id}"))
8199                    .header(header::COOKIE, cookie.clone())
8200                    .body(Body::empty())
8201                    .unwrap(),
8202            )
8203            .await
8204            .unwrap();
8205        assert_eq!(
8206            get_b.status(),
8207            StatusCode::NOT_FOUND,
8208            "A must not read B's entry during a PDS outage"
8209        );
8210
8211        // POST /entries/{b}/read as A → 404, and no entry_state row is written.
8212        let read_b = app
8213            .oneshot(
8214                Request::builder()
8215                    .method("POST")
8216                    .uri(format!("/entries/{b_entry_id}/read"))
8217                    .header(header::COOKIE, cookie)
8218                    .header("content-type", "application/x-www-form-urlencoded")
8219                    .body(Body::from("read=true"))
8220                    .unwrap(),
8221            )
8222            .await
8223            .unwrap();
8224        assert_eq!(
8225            read_b.status(),
8226            StatusCode::NOT_FOUND,
8227            "A must not mark B's entry read during a PDS outage"
8228        );
8229
8230        // The fallback must NOT have widened A's sub_ref to feed_b.
8231        let a_feed_ids: Vec<i64> = sqlx::query_scalar("SELECT feed_id FROM sub_ref WHERE did = ?1")
8232            .bind(did_a)
8233            .fetch_all(&state.db)
8234            .await
8235            .unwrap();
8236        assert_eq!(
8237            a_feed_ids,
8238            vec![feed_a],
8239            "outage fallback must not add feeds A never subscribed to"
8240        );
8241        // And B's entry has zero read-state (A's attempt did not mutate).
8242        let es_count: i64 =
8243            sqlx::query_scalar("SELECT COUNT(*) FROM entry_state WHERE did = ?1 AND entry_id = ?2")
8244                .bind(did_a)
8245                .bind(b_entry_id)
8246                .fetch_one(&state.db)
8247                .await
8248                .unwrap();
8249        assert_eq!(es_count, 0, "no cross-DID mutation during the outage");
8250    }
8251
8252    /// **A logout with nothing to revoke is a SUCCESS — on the arm that had
8253    /// nothing. The other arm is counted separately.**
8254    ///
8255    /// Logout is idempotent, so `NoSession` must record as ok; counting it as an
8256    /// error would make the metric noisy in exactly the case that is fine.
8257    ///
8258    /// But `revoke_everywhere` has TWO arms, and a review found that counting
8259    /// only the rust one let `oauth_revoke` report all-clear while every sidecar
8260    /// revocation failed. For anyone who logged in before the cutover the sidecar
8261    /// store is the only one holding tokens, so the rust arm correctly says
8262    /// NoSession and the metric said nothing was wrong. Both arms are now
8263    /// recorded, distinguished by the backend column — so this test pins the
8264    /// BACKEND as well as the outcome.
8265    #[tokio::test]
8266    async fn a_logout_with_no_session_counts_as_success() {
8267        let did = "did:plc:aaaa";
8268        let state = test_state(&[]).await;
8269        assert!(
8270            state.oauth.is_some(),
8271            "meaningless without an oauth runtime; the revoke arm would be skipped",
8272        );
8273
8274        revoke_everywhere(&state, did).await;
8275        let rows = state.metrics.snapshot();
8276        let find = |b: crate::metrics::Backend| {
8277            rows.iter()
8278                .find(|r| r.op == "oauth_revoke" && r.backend == b)
8279                .unwrap_or_else(|| panic!("no oauth_revoke row for {b:?}"))
8280        };
8281
8282        // Rust arm: nothing stored for this DID, so NoSession -> ok.
8283        let rust = find(crate::metrics::Backend::Rust);
8284        assert_eq!(
8285            rust.stats.err_count, 0,
8286            "NoSession was counted as a failure; logout is idempotent",
8287        );
8288        assert_eq!(rust.stats.ok_count, 1);
8289
8290        // Sidecar arm: unreachable in a test, so it must be recorded as an
8291        // ERROR under its own backend — not silently dropped, and not folded
8292        // into the rust row.
8293        let sidecar = find(crate::metrics::Backend::Sidecar);
8294        assert_eq!(
8295            sidecar.stats.err_count, 1,
8296            "a failed sidecar revoke was not counted",
8297        );
8298    }
8299
8300    /// **`Failed` must count as an error — the half the metric exists for.**
8301    ///
8302    /// A review found this unpinned: replacing the mapping with
8303    /// `let revoke_ok = true;` passed all 682 tests. The only revoke test
8304    /// asserted the `NoSession -> ok` half, so the branch that actually means
8305    /// "the PDS still holds tokens we asked it to drop" was untested.
8306    ///
8307    /// Driven through the same handler, with a session present but the PDS
8308    /// unreachable, so `sign_out_discovering` returns `Failed`.
8309    #[tokio::test]
8310    async fn a_failed_rust_revoke_counts_as_an_error() {
8311        let did = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
8312        let state = test_state(&[]).await;
8313        let runtime = state.oauth.as_deref().expect("oauth runtime");
8314        crate::oauth::store::put_session(
8315            &state.db,
8316            &runtime.codec,
8317            &crate::oauth::store::OAuthSession {
8318                sub: did.into(),
8319                issuer: "https://auth.invalid".into(),
8320                aud: "https://pds.invalid".into(),
8321                dpop_key_jwk: crate::oauth::keys::SigningKey::generate("session-dpop")
8322                    .to_jwk_json()
8323                    .unwrap(),
8324                access_token: "at".into(),
8325                refresh_token: "rt".into(),
8326                token_type: "DPoP".into(),
8327                granted_scope: "atproto".into(),
8328                expires_at: Some(crate::store::now_unix() + 3600),
8329            },
8330        )
8331        .await
8332        .unwrap();
8333
8334        revoke_everywhere(&state, did).await;
8335
8336        let rows = state.metrics.snapshot();
8337        let rust = rows
8338            .iter()
8339            .find(|r| r.op == "oauth_revoke" && r.backend == crate::metrics::Backend::Rust)
8340            .expect("no rust oauth_revoke row");
8341        assert_eq!(
8342            rust.stats.err_count, 1,
8343            "an unreachable PDS must count as a revocation failure",
8344        );
8345        assert_eq!(rust.stats.ok_count, 0);
8346    }
8347
8348    /// **The `href` defence is now carried by the TYPE, not by remembering.**
8349    ///
8350    /// `EntryRow.link` used to be a `String`, and the guard was "call
8351    /// `net::safe_link` before assigning it". Deleting that call left all 679
8352    /// tests passing — a live XSS defence with nothing protecting it.
8353    ///
8354    /// `SafeLink` has no `From<String>` and no public member, so the only way to
8355    /// get foreign input into an `href` is `external`, which does the check
8356    /// itself. This test pins that constructor; the *wiring* is now pinned by
8357    /// the compiler, which is the part a test could never hold down.
8358    ///
8359    /// Note the empty-not-absent behaviour: a rejected URL yields an EMPTY link
8360    /// so the template renders the row WITHOUT an anchor. Dropping the row
8361    /// instead would make the record unremovable, because the un-save button
8362    /// lives on it.
8363    #[test]
8364    fn a_hostile_scheme_cannot_reach_an_href_through_safelink() {
8365        for hostile in [
8366            "javascript:alert(1)",
8367            "JavaScript:alert(1)",
8368            "  javascript:alert(1)",
8369            "data:text/html;base64,PHNjcmlwdD4=",
8370            "vbscript:msgbox(1)",
8371            "file:///etc/passwd",
8372            // Protocol-relative: inherits the page's scheme, so it is an
8373            // off-site link wearing a same-site costume. Carried over from the
8374            // test this one replaces, which was its only unique input.
8375            "//evil.example/path",
8376        ] {
8377            let link = SafeLink::external(hostile);
8378            assert!(
8379                link.is_empty(),
8380                "{hostile:?} produced a non-empty href: {link}",
8381            );
8382            assert!(
8383                !link.to_string().to_ascii_lowercase().contains("script"),
8384                "{hostile:?} leaked into the rendered link",
8385            );
8386        }
8387
8388        // And the other direction: a check that rejects everything would satisfy
8389        // the loop above while breaking every real saved record.
8390        for good in ["https://example.com/a?b=c#d", "http://example.com/"] {
8391            let link = SafeLink::external(good);
8392            assert!(!link.is_empty(), "{good:?} was wrongly rejected");
8393            assert_eq!(link.to_string(), good);
8394        }
8395    }
8396
8397    /// **The WIRING, not the helper — this is the one that catches the real
8398    /// mistake.**
8399    ///
8400    /// `a_hostile_scheme_cannot_reach_an_href_through_safelink` pins what
8401    /// `SafeLink::external` *does*. It cannot pin that the saved-record path
8402    /// *calls* it, and a review proved that gap was live twice over: swapping
8403    /// `external` for the app-path constructor, and constructing the tuple
8404    /// directly, both restored the whole `javascript:` hole with every test
8405    /// green. The type now blocks both — `entry` takes an `i64`, and the field
8406    /// lives in another module — but the wiring deserves a test of its own
8407    /// rather than resting on the shape of a signature.
8408    ///
8409    /// Renders the actual row through the actual handler, from a record whose
8410    /// URL is hostile.
8411    #[tokio::test]
8412    async fn a_saved_record_with_a_hostile_url_renders_no_anchor() {
8413        let did = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
8414        let sidecar = spawn_saved_sidecar("javascript:alert(1)", "Hostile record").await;
8415        let mut state = test_state_with_sidecar(&[did], &sidecar).await;
8416        std::sync::Arc::get_mut(&mut state.config).unwrap().dev_did = Some(did.to_string());
8417
8418        let resp = router(state)
8419            .oneshot(
8420                Request::builder()
8421                    .uri("/?view=starred")
8422                    .body(Body::empty())
8423                    .unwrap(),
8424            )
8425            .await
8426            .unwrap();
8427        assert_eq!(resp.status(), StatusCode::OK);
8428        let body = String::from_utf8(
8429            axum::body::to_bytes(resp.into_body(), usize::MAX)
8430                .await
8431                .unwrap()
8432                .to_vec(),
8433        )
8434        .unwrap();
8435
8436        // Not in an href, and not as the title either — the title falls back to
8437        // the URL for links we DO render, so both paths must withhold it.
8438        assert!(
8439            !body.to_ascii_lowercase().contains("javascript:"),
8440            "the hostile scheme reached the rendered page",
8441        );
8442        // But the row must survive: the un-save button lives on it, so dropping
8443        // the row would make the record unremovable from here.
8444        assert!(
8445            body.contains("unusable link"),
8446            "the row was dropped instead of rendering without an anchor",
8447        );
8448    }
8449
8450    /// **The reader view's two `href`s, through the actual handler.**
8451    ///
8452    /// The sibling above covers the LIST row. `entry.html` has its own pair of
8453    /// `href`s fed by `EntryTemplate.url`, and they were a raw `Option<String>`
8454    /// taken straight off the `entries.url` column — a remote feed's `<link>`.
8455    ///
8456    /// Ingest already scheme-checks that column (`feed.rs`'s `entry_link`), so
8457    /// this was never a live hole. But that guard is procedural and sits a long
8458    /// way from the `href`: it holds only as long as every future writer to
8459    /// `entries.url` remembers to go through `feed.rs`. This test does not
8460    /// depend on it — it writes the hostile URL into the column DIRECTLY, which
8461    /// is precisely the state the ingest check cannot speak for.
8462    ///
8463    /// **Both directions, deliberately.** A fix that renders no link at all
8464    /// satisfies every negative assertion here, and would break every real
8465    /// entry. The second half is what makes the first half mean something.
8466    #[tokio::test]
8467    async fn a_hostile_entry_url_renders_the_reader_without_an_original_link() {
8468        let did = "did:plc:readerhref";
8469        let state = test_state(&[]).await;
8470        store::grant_access(&state.db, did, None, "test", None)
8471            .await
8472            .unwrap();
8473        let feed = store::upsert_feed(
8474            &state.db,
8475            &store::NewFeed {
8476                url: "https://href.example/feed.xml".to_string(),
8477                title: Some("Href".to_string()),
8478                ..Default::default()
8479            },
8480        )
8481        .await
8482        .unwrap();
8483        // Straight into the column, bypassing `feed.rs` — the whole point.
8484        store::insert_entries(
8485            &state.db,
8486            feed,
8487            &[
8488                store::NewEntry {
8489                    guid: "hostile-1".to_string(),
8490                    url: Some("javascript:alert(1)".to_string()),
8491                    title: Some("Hostile entry".to_string()),
8492                    published: Some("2026-07-11T00:00:00Z".to_string()),
8493                    ..Default::default()
8494                },
8495                store::NewEntry {
8496                    guid: "benign-1".to_string(),
8497                    url: Some("https://href.example/post".to_string()),
8498                    title: Some("Benign entry".to_string()),
8499                    published: Some("2026-07-10T00:00:00Z".to_string()),
8500                    ..Default::default()
8501                },
8502            ],
8503            0,
8504        )
8505        .await
8506        .unwrap();
8507        store::replace_sub_refs(&state.db, did, &[feed])
8508            .await
8509            .unwrap();
8510        let rows = store::entries_for_feed(&state.db, did, feed).await.unwrap();
8511        let id_of = |guid: &str| {
8512            rows.iter()
8513                .find(|r| r.guid == guid)
8514                .unwrap_or_else(|| panic!("{guid} was not inserted"))
8515                .id
8516        };
8517
8518        let cookie = session_cookie(&state, did, None);
8519        let app = router(state.clone());
8520
8521        let render = |id: i64| {
8522            let app = app.clone();
8523            let cookie = cookie.clone();
8524            async move {
8525                let resp = app
8526                    .oneshot(
8527                        Request::builder()
8528                            .method("GET")
8529                            .uri(format!("/entries/{id}"))
8530                            .header(header::COOKIE, cookie)
8531                            .body(Body::empty())
8532                            .unwrap(),
8533                    )
8534                    .await
8535                    .unwrap();
8536                assert_eq!(resp.status(), StatusCode::OK);
8537                String::from_utf8(
8538                    axum::body::to_bytes(resp.into_body(), usize::MAX)
8539                        .await
8540                        .unwrap()
8541                        .to_vec(),
8542                )
8543                .unwrap()
8544            }
8545        };
8546
8547        let hostile = render(id_of("hostile-1")).await;
8548        // The reader page for THIS entry actually rendered. Without this the
8549        // three negatives below are satisfied by an empty body.
8550        assert!(
8551            hostile.contains("Hostile entry"),
8552            "the reader did not render the entry: {hostile}",
8553        );
8554        assert!(
8555            !hostile.to_ascii_lowercase().contains("javascript:"),
8556            "the hostile scheme reached the reader page: {hostile}",
8557        );
8558        // Not merely escaped — the template took its no-link branch. Both
8559        // `href`s are gated on the same `Option`, so this covers the byline
8560        // link and the action-bar button together.
8561        assert!(
8562            !hostile.contains("actionbar-open"),
8563            "the action bar rendered an open-original link for a refused URL: {hostile}",
8564        );
8565        assert!(
8566            !hostile.contains("Original \u{2197}"),
8567            "the byline rendered an original link for a refused URL: {hostile}",
8568        );
8569
8570        // The other direction: a legitimate entry still links out, so "render
8571        // nothing" cannot pass as a fix.
8572        let benign = render(id_of("benign-1")).await;
8573        assert!(
8574            benign.contains("Benign entry"),
8575            "the reader did not render the benign entry: {benign}",
8576        );
8577        // BOTH `href`s, counted. The negatives above fire on the action bar
8578        // first, so without this the byline needle `Original \u{2197}` is never
8579        // once observed failing — a misspelled needle would pass forever.
8580        assert_eq!(
8581            benign
8582                .matches(r#"href="https://href.example/post""#)
8583                .count(),
8584            2,
8585            "entry.html has two `href`s for the entry URL — the byline link and \
8586             the action-bar button — and this render produced a different \
8587             number: {benign}",
8588        );
8589        assert!(
8590            benign.contains("actionbar-open"),
8591            "a legitimate entry lost its open-original button: {benign}",
8592        );
8593        assert!(
8594            benign.contains("Original \u{2197}"),
8595            "a legitimate entry lost its byline link: {benign}",
8596        );
8597    }
8598
8599    /// **The outage fallback must not widen what the caller can READ — and the
8600    /// sibling test above can only see what it WRITES.**
8601    ///
8602    /// `pds_outage_does_not_widen_cross_did_access` asserts on `sub_ref` rows and
8603    /// on `entry_state`: the fallback's side effects. But the fail-open it names
8604    /// returns **early**, before `sync_sub_refs` runs, so it touches neither. It
8605    /// leaks through the list it *hands back* — the sidebar and the reader render
8606    /// from that list, so a caller sees another DID's feeds while `sub_ref` stays
8607    /// perfectly honest and every existing assertion stays green.
8608    ///
8609    /// Measured, not assumed: replacing `feeds_for_did(pool, did)` with
8610    /// `due_feeds(pool, "9999-…", 10_000)` over the whole shared cache — the
8611    /// exact historical bug the fallback's comment describes — left **all 663
8612    /// tests passing**. Cross-tenant isolation is the one property this project
8613    /// cannot regress quietly, and nothing observed it.
8614    ///
8615    /// So this asserts on the RETURN VALUE, which is the thing that reaches the
8616    /// user, and it deliberately does not look at `sub_ref` at all — that half is
8617    /// already covered above.
8618    #[tokio::test]
8619    async fn the_outage_fallback_returns_only_the_callers_own_feeds() {
8620        let did_a = "did:plc:aaaa";
8621        let state = test_state(&[]).await;
8622        store::grant_access(&state.db, did_a, None, "test", None)
8623            .await
8624            .unwrap();
8625
8626        let feed_a = store::upsert_feed(
8627            &state.db,
8628            &store::NewFeed {
8629                url: "https://a.example/feed.xml".to_string(),
8630                title: Some("A".to_string()),
8631                ..Default::default()
8632            },
8633        )
8634        .await
8635        .unwrap();
8636        let _feed_b = store::upsert_feed(
8637            &state.db,
8638            &store::NewFeed {
8639                url: "https://b.example/feed.xml".to_string(),
8640                title: Some("B".to_string()),
8641                ..Default::default()
8642            },
8643        )
8644        .await
8645        .unwrap();
8646        // A subscribes ONLY to feed_a. feed_b is in the shared cache and belongs
8647        // to nobody — exactly the row a whole-cache fallback would hand to A.
8648        store::replace_sub_refs(&state.db, did_a, &[feed_a])
8649            .await
8650            .unwrap();
8651
8652        // No sidecar and no PDS are reachable from a test, so
8653        // `list_subscriptions_sorted` fails and this IS the outage path. Assert
8654        // that, rather than assuming it: if the repo ever starts succeeding here,
8655        // this test would silently stop exercising the fallback at all.
8656        assert!(
8657            state.repo().list_subscriptions_sorted(did_a).await.is_err(),
8658            "this test is only meaningful on the outage path; the repo answered",
8659        );
8660
8661        let resolved = resolve_subscriptions(&state, did_a).await;
8662
8663        let urls: Vec<&str> = resolved.iter().map(|r| r.sub.url.as_str()).collect();
8664        assert_eq!(
8665            urls,
8666            vec!["https://a.example/feed.xml"],
8667            "the outage fallback must return the caller's OWN subscriptions only; \
8668             any other feed here is cross-tenant read access granted by an outage",
8669        );
8670    }
8671
8672    /// Build an [`AppState`] over a fresh in-memory DB with explicit feed caps,
8673    /// seeding `did` a beta seat + session-capable state.
8674    async fn test_state_with_caps(
8675        did: &str,
8676        max_subs_per_did: i64,
8677        max_feeds_global: i64,
8678    ) -> AppState {
8679        let db = store::init_url("sqlite::memory:").await.unwrap();
8680        let config = Config {
8681            cookie_secret: "test-cookie-secret-000".to_string(),
8682            beta_cap: 100,
8683            max_subs_per_did,
8684            max_feeds_global,
8685            ..Config::default()
8686        };
8687        store::grant_access(&db, did, None, "test", None)
8688            .await
8689            .unwrap();
8690        AppState::new(config, db).unwrap()
8691    }
8692
8693    /// An OPML document with `n` distinct public feeds.
8694    fn opml_with_feeds(n: usize) -> String {
8695        let mut outlines = String::new();
8696        for i in 0..n {
8697            outlines.push_str(&format!(
8698                "<outline type=\"rss\" text=\"F{i}\" xmlUrl=\"https://f{i}.example/feed.xml\"/>\n"
8699            ));
8700        }
8701        format!(
8702            "<?xml version=\"1.0\"?>\n<opml version=\"2.0\"><head><title>t</title></head><body>\n{outlines}</body></opml>"
8703        )
8704    }
8705
8706    /// OPML bulk import must honour the GLOBAL feeds ceiling: importing more
8707    /// distinct new feeds than the shared cache can hold caches only up to the
8708    /// ceiling — the rest are trimmed. (Regression: the import loop previously
8709    /// bypassed `max_feeds_global` entirely.)
8710    #[tokio::test]
8711    async fn opml_import_enforces_global_feeds_ceiling() {
8712        let did = "did:plc:importer";
8713        // Cap the shared cache at 3 feeds; import 10 distinct new ones.
8714        let state = test_state_with_caps(did, 0, 3).await;
8715        let cookie = session_cookie(&state, did, None);
8716        let (ct, body) = opml_multipart(opml_with_feeds(10).as_bytes());
8717        let app = router(state.clone());
8718
8719        let resp = app
8720            .oneshot(
8721                Request::builder()
8722                    .method("POST")
8723                    .uri("/opml")
8724                    .header(header::COOKIE, cookie)
8725                    .header("content-type", ct)
8726                    .body(Body::from(body))
8727                    .unwrap(),
8728            )
8729            .await
8730            .unwrap();
8731        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8732
8733        let feeds = store::count_feeds(&state.db).await.unwrap();
8734        assert!(
8735            feeds <= 3,
8736            "OPML import blew past the global ceiling: {feeds} feeds cached with cap=3"
8737        );
8738    }
8739
8740    /// **A malformed `at://` on the add path is "not a kind of feed we take",
8741    /// not "private/paid".** The first gate was the privacy classifier, whose
8742    /// at:// arm fails closed as `Private` for anything not a well-formed
8743    /// publication URI — so a typo (`did:plc:TOOSHORT`, a missing rkey) drew
8744    /// the private-feed flash and a "refused private/paid feed" log line. On
8745    /// main the same input reached `resolve_feed_url` and got "Couldn't find a
8746    /// feed". Storability is decided first for an at:// input, with its own
8747    /// message.
8748    #[tokio::test]
8749    async fn a_malformed_at_uri_on_the_add_path_is_refused_as_unsupported_not_private() {
8750        let did = "did:plc:typoist";
8751        let state = test_state_with_caps(did, 0, 0).await;
8752        let cookie = session_cookie(&state, did, None);
8753        for input in [
8754            "at%3A%2F%2Falice.example.com%2Fsite.standard.publication",
8755            "at%3A%2F%2Fdid%3Aplc%3ATOOSHORT%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
8756        ] {
8757            let resp = router(state.clone())
8758                .oneshot(
8759                    Request::builder()
8760                        .method("POST")
8761                        .uri("/subscriptions")
8762                        .header(header::COOKIE, cookie.clone())
8763                        .header("content-type", "application/x-www-form-urlencoded")
8764                        .body(Body::from(format!("url={input}")))
8765                        .unwrap(),
8766                )
8767                .await
8768                .unwrap();
8769            assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8770            let loc = resp
8771                .headers()
8772                .get(header::LOCATION)
8773                .unwrap()
8774                .to_str()
8775                .unwrap();
8776            assert!(
8777                loc.contains("kind%20of%20feed"),
8778                "expected the unsupported-feed flash for {input}, got {loc}"
8779            );
8780            assert!(
8781                !loc.contains("Private"),
8782                "a storability refusal was reported as a privacy one for {input}: {loc}"
8783            );
8784        }
8785        assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
8786    }
8787
8788    /// **An OPML entry this instance cannot store is counted and reported, not
8789    /// silently dropped.** The storability `continue` incremented nothing,
8790    /// while the privacy branch beside it produced a user-visible label — so
8791    /// an OPML exported from a standard.site-enabled instance imported
8792    /// "successfully" with entries missing and no reason given. The reader is
8793    /// told how many, and why.
8794    #[tokio::test]
8795    async fn opml_import_reports_entries_this_instance_cannot_store() {
8796        let did = "did:plc:renamer4";
8797        let (sidecar, _puts) = spawn_rename_sidecar(seeded_subscription()).await;
8798        let state = test_state_with_sidecar(&[did], &sidecar).await;
8799        assert!(!state.config.standard_site);
8800        let opml = format!(
8801            "<?xml version=\"1.0\"?>\n<opml version=\"2.0\"><head><title>t</title></head><body>\n\
8802             <outline type=\"rss\" text=\"Real\" xmlUrl=\"https://real.example/feed.xml\"/>\n\
8803             <outline type=\"rss\" text=\"Pub\" xmlUrl=\"{AT_URI_SUB}\"/>\n\
8804             </body></opml>"
8805        );
8806        let (ct, body) = opml_multipart(opml.as_bytes());
8807        let cookie = session_cookie(&state, did, None);
8808        let resp = router(state.clone())
8809            .oneshot(
8810                Request::builder()
8811                    .method("POST")
8812                    .uri("/opml")
8813                    .header(header::COOKIE, cookie)
8814                    .header("content-type", ct)
8815                    .body(Body::from(body))
8816                    .unwrap(),
8817            )
8818            .await
8819            .unwrap();
8820        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8821        let loc = resp
8822            .headers()
8823            .get(header::LOCATION)
8824            .unwrap()
8825            .to_str()
8826            .unwrap();
8827        assert!(
8828            loc.contains("Imported%201%20feed"),
8829            "unexpected flash: {loc}"
8830        );
8831        assert!(
8832            loc.contains("1%20feed%28s%29%20skipped") && loc.contains("can%20subscribe%20to"),
8833            "the dropped entry was not reported: {loc}"
8834        );
8835        // Reported by count only: the at-URI itself is not echoed back.
8836        assert!(
8837            !loc.contains("site.standard.publication"),
8838            "the URI was echoed: {loc}"
8839        );
8840    }
8841
8842    /// OPML bulk import must honour the PER-DID subscription cap: a DID at its
8843    /// cap imports zero new feeds.
8844    #[tokio::test]
8845    async fn opml_import_enforces_per_did_cap() {
8846        let did = "did:plc:capped";
8847        // Per-DID cap 2, global unlimited. Pre-seed the DID at its cap.
8848        let state = test_state_with_caps(did, 2, 0).await;
8849        let existing_a = store::upsert_feed(
8850            &state.db,
8851            &store::NewFeed {
8852                url: "https://have-a.example/feed.xml".to_string(),
8853                ..Default::default()
8854            },
8855        )
8856        .await
8857        .unwrap();
8858        let existing_b = store::upsert_feed(
8859            &state.db,
8860            &store::NewFeed {
8861                url: "https://have-b.example/feed.xml".to_string(),
8862                ..Default::default()
8863            },
8864        )
8865        .await
8866        .unwrap();
8867        store::replace_sub_refs(&state.db, did, &[existing_a, existing_b])
8868            .await
8869            .unwrap();
8870        let before = store::count_feeds(&state.db).await.unwrap();
8871
8872        let cookie = session_cookie(&state, did, None);
8873        let (ct, body) = opml_multipart(opml_with_feeds(10).as_bytes());
8874        let app = router(state.clone());
8875        let resp = app
8876            .oneshot(
8877                Request::builder()
8878                    .method("POST")
8879                    .uri("/opml")
8880                    .header(header::COOKIE, cookie)
8881                    .header("content-type", ct)
8882                    .body(Body::from(body))
8883                    .unwrap(),
8884            )
8885            .await
8886            .unwrap();
8887        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8888        // Headroom was 0 → no new feeds imported into the shared cache.
8889        let after = store::count_feeds(&state.db).await.unwrap();
8890        assert_eq!(after, before, "over-cap DID imported new feeds anyway");
8891    }
8892
8893    /// Single-add per-DID cap: a DID at its subscription cap is refused before
8894    /// any fetch, with the limit flash.
8895    #[tokio::test]
8896    async fn single_add_enforces_per_did_cap() {
8897        let did = "did:plc:subcapped";
8898        let state = test_state_with_caps(did, 1, 0).await;
8899        let f = store::upsert_feed(
8900            &state.db,
8901            &store::NewFeed {
8902                url: "https://have.example/feed.xml".to_string(),
8903                ..Default::default()
8904            },
8905        )
8906        .await
8907        .unwrap();
8908        store::replace_sub_refs(&state.db, did, &[f]).await.unwrap();
8909        let cookie = session_cookie(&state, did, None);
8910        let app = router(state.clone());
8911        let resp = app
8912            .oneshot(
8913                Request::builder()
8914                    .method("POST")
8915                    .uri("/subscriptions")
8916                    .header(header::COOKIE, cookie)
8917                    .header("content-type", "application/x-www-form-urlencoded")
8918                    .body(Body::from("url=https://another.example/feed.xml"))
8919                    .unwrap(),
8920            )
8921            .await
8922            .unwrap();
8923        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8924        let loc = resp
8925            .headers()
8926            .get(header::LOCATION)
8927            .unwrap()
8928            .to_str()
8929            .unwrap();
8930        assert!(
8931            loc.contains("Subscription%20limit%20reached"),
8932            "expected sub-limit flash, got {loc}"
8933        );
8934    }
8935
8936    /// `GET /` renders at most one page of rows and offers a way to the rest.
8937    ///
8938    /// The handler used to materialize EVERY unread entry — `SELECT e.*`, no
8939    /// `LIMIT`, article bodies included — and hand the lot to the template. With
8940    /// 250 entries that is the whole list in one response; with a real backlog on
8941    /// a 512 MB box it is the OOM the operator review flagged. Asserts the page
8942    /// is capped, the heading still reports the true total, and page 2 is
8943    /// reachable and disjoint.
8944    #[tokio::test]
8945    async fn the_reader_index_pages_instead_of_rendering_everything() {
8946        let did = "did:plc:pager";
8947        let state = test_state(&[]).await;
8948        store::grant_access(&state.db, did, None, "test", None)
8949            .await
8950            .unwrap();
8951        let feed = store::upsert_feed(
8952            &state.db,
8953            &store::NewFeed {
8954                url: "https://pager.example/feed.xml".to_string(),
8955                title: Some("Pager".to_string()),
8956                ..Default::default()
8957            },
8958        )
8959        .await
8960        .unwrap();
8961        let total = 250_usize;
8962        let entries: Vec<store::NewEntry> = (0..total)
8963            .map(|i| store::NewEntry {
8964                guid: format!("p-{i:04}"),
8965                url: Some(format!("https://pager.example/{i}")),
8966                title: Some(format!("Article {i:04}")),
8967                published: Some(format!("2026-07-{:02}T00:00:00Z", (i % 28) + 1)),
8968                content_html: Some("x".repeat(4_000)),
8969                ..Default::default()
8970            })
8971            .collect();
8972        store::insert_entries(&state.db, feed, &entries, 0)
8973            .await
8974            .unwrap();
8975        store::replace_sub_refs(&state.db, did, &[feed])
8976            .await
8977            .unwrap();
8978
8979        let cookie = session_cookie(&state, did, None);
8980        let app = router(state.clone());
8981        let get = |uri: &str| {
8982            let app = app.clone();
8983            let cookie = cookie.clone();
8984            let uri = uri.to_string();
8985            async move {
8986                let resp = app
8987                    .oneshot(
8988                        Request::builder()
8989                            .uri(uri)
8990                            .header(header::COOKIE, cookie)
8991                            .body(Body::empty())
8992                            .unwrap(),
8993                    )
8994                    .await
8995                    .unwrap();
8996                assert_eq!(resp.status(), StatusCode::OK);
8997                let bytes = axum::body::to_bytes(resp.into_body(), 8 * 1024 * 1024)
8998                    .await
8999                    .unwrap();
9000                String::from_utf8(bytes.to_vec()).unwrap()
9001            }
9002        };
9003
9004        let page1 = get("/").await;
9005        // One `<li class="entry…>` per rendered row. Counting "/entries/" would
9006        // over-count: each row carries several (the link plus the read/star
9007        // forms).
9008        let rows1 = page1.matches("<li class=\"entry").count();
9009        assert!(
9010            rows1 <= ENTRIES_PER_PAGE as usize,
9011            "page 1 rendered {rows1} entry links; the list is unbounded"
9012        );
9013        assert!(
9014            rows1 > 0,
9015            "page 1 rendered nothing at all: the page bound swallowed the list"
9016        );
9017        // The count is the TRUE total, not the page size — otherwise paging
9018        // would quietly relabel a 250-entry backlog as a 100-entry one.
9019        assert!(
9020            page1.contains("250 entries"),
9021            "heading must report the full total, not the page"
9022        );
9023        assert!(
9024            page1.contains("page=2"),
9025            "no way to reach the rest of the list: {}",
9026            &page1[..page1.len().min(400)]
9027        );
9028        // The body never belongs in a list response.
9029        assert!(
9030            !page1.contains(&"x".repeat(4_000)),
9031            "the list response carried an article body"
9032        );
9033
9034        let page2 = get("/?page=2").await;
9035        assert!(
9036            page2.matches("<li class=\"entry").count() > 0,
9037            "page 2 rendered no rows at all"
9038        );
9039        assert!(
9040            page2.contains("page=1") || page2.contains("Newer"),
9041            "page 2 offers no way back"
9042        );
9043        // Disjoint: an article on page 1 must not reappear on page 2.
9044        let first_title = (0..total)
9045            .map(|i| format!("Article {i:04}"))
9046            .find(|t| page1.contains(t))
9047            .expect("page 1 shows at least one titled article");
9048        assert!(
9049            !page2.contains(&first_title),
9050            "{first_title} appears on both pages"
9051        );
9052
9053        // A page past the end must not be a dead end. The empty state renders
9054        // instead of the pager, so an out-of-range page would leave a reader
9055        // with no link back — reachable by typing a number, and reachable
9056        // WITHOUT typing anything by paging to the end and then marking entries
9057        // read, which shrinks the list under the URL already in the address bar.
9058        let past_end = get("/?page=999").await;
9059        assert!(
9060            past_end.matches("<li class=\"entry").count() > 0,
9061            "an out-of-range page rendered nothing and offered no way back"
9062        );
9063        assert!(
9064            past_end.contains("page=2"),
9065            "the clamped page offers no pager"
9066        );
9067    }
9068
9069    /// Reader-view mark-read (a request tagged `X-FR-Reader: 1`) must return the
9070    /// out-of-band action-bar fragment with FRESHLY re-read state so a second
9071    /// keypress reverses the toggle: `hx-swap-oob="outerHTML"` is present, and
9072    /// the hidden `read` input + `aria-pressed` reflect the NEW state. The list
9073    /// view (no reader header) instead swaps the row. This guards the reader OOB
9074    /// toggle wiring, which had no test.
9075    #[tokio::test]
9076    async fn reader_mark_read_returns_oob_actionbar_with_flipped_state() {
9077        let did = "did:plc:reader";
9078        let state = test_state(&[]).await;
9079        store::grant_access(&state.db, did, None, "test", None)
9080            .await
9081            .unwrap();
9082        let feed = store::upsert_feed(
9083            &state.db,
9084            &store::NewFeed {
9085                url: "https://reader.example/feed.xml".to_string(),
9086                title: Some("Reader".to_string()),
9087                ..Default::default()
9088            },
9089        )
9090        .await
9091        .unwrap();
9092        store::insert_entries(
9093            &state.db,
9094            feed,
9095            &[store::NewEntry {
9096                guid: "r-1".to_string(),
9097                url: Some("https://reader.example/1".to_string()),
9098                title: Some("Article".to_string()),
9099                published: Some("2026-07-11T00:00:00Z".to_string()),
9100                content_html: Some("<p>body</p>".to_string()),
9101                ..Default::default()
9102            }],
9103            0,
9104        )
9105        .await
9106        .unwrap();
9107        store::replace_sub_refs(&state.db, did, &[feed])
9108            .await
9109            .unwrap();
9110        let entry_id = store::entries_for_feed(&state.db, did, feed).await.unwrap()[0].id;
9111
9112        let cookie = session_cookie(&state, did, None);
9113        let app = router(state.clone());
9114
9115        // Reader-tagged mark-read → OOB action-bar fragment, entry now READ.
9116        let resp = app
9117            .clone()
9118            .oneshot(
9119                Request::builder()
9120                    .method("POST")
9121                    .uri(format!("/entries/{entry_id}/read"))
9122                    .header(header::COOKIE, cookie.clone())
9123                    .header("HX-Request", "true")
9124                    .header("X-FR-Reader", "1")
9125                    .header("content-type", "application/x-www-form-urlencoded")
9126                    .body(Body::from("read=true"))
9127                    .unwrap(),
9128            )
9129            .await
9130            .unwrap();
9131        assert_eq!(resp.status(), StatusCode::OK);
9132        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
9133            .await
9134            .unwrap();
9135        let html = String::from_utf8(bytes.to_vec()).unwrap();
9136        assert!(
9137            html.contains("hx-swap-oob=\"outerHTML\""),
9138            "reader response must be an OOB swap: {html}"
9139        );
9140        assert!(
9141            html.contains(r#"id="entry-actionbar""#),
9142            "reader response must be the action-bar fragment: {html}"
9143        );
9144        // Now READ: the read button reflects it (aria-pressed=true) and the
9145        // hidden value flips to `false` so the next tap marks it UNREAD.
9146        assert!(
9147            html.contains(r#"aria-pressed="true""#),
9148            "read button must show pressed after marking read: {html}"
9149        );
9150        assert!(
9151            html.contains(r#"name="read" value="false""#),
9152            "hidden read value must flip to false so a second tap reverses: {html}"
9153        );
9154
9155        // A second reader mark-read (submitting the flipped `read=false`) marks
9156        // it UNREAD again — the toggle reverses.
9157        let resp2 = app
9158            .oneshot(
9159                Request::builder()
9160                    .method("POST")
9161                    .uri(format!("/entries/{entry_id}/read"))
9162                    .header(header::COOKIE, cookie)
9163                    .header("HX-Request", "true")
9164                    .header("X-FR-Reader", "1")
9165                    .header("content-type", "application/x-www-form-urlencoded")
9166                    .body(Body::from("read=false"))
9167                    .unwrap(),
9168            )
9169            .await
9170            .unwrap();
9171        assert_eq!(resp2.status(), StatusCode::OK);
9172        let bytes2 = axum::body::to_bytes(resp2.into_body(), 64 * 1024)
9173            .await
9174            .unwrap();
9175        let html2 = String::from_utf8(bytes2.to_vec()).unwrap();
9176        assert!(
9177            html2.contains(r#"aria-pressed="false""#),
9178            "read button must show un-pressed after reversing: {html2}"
9179        );
9180        assert!(
9181            html2.contains(r#"name="read" value="true""#),
9182            "hidden read value must flip back to true: {html2}"
9183        );
9184    }
9185
9186    /// The LIST view (no `X-FR-Reader` header) swaps the row, not the OOB
9187    /// action-bar — the counterpart to the reader-OOB test above.
9188    #[tokio::test]
9189    async fn list_mark_read_returns_row_not_oob_actionbar() {
9190        let did = "did:plc:listv";
9191        let state = test_state(&[]).await;
9192        store::grant_access(&state.db, did, None, "test", None)
9193            .await
9194            .unwrap();
9195        let feed = store::upsert_feed(
9196            &state.db,
9197            &store::NewFeed {
9198                url: "https://list.example/feed.xml".to_string(),
9199                title: Some("List".to_string()),
9200                ..Default::default()
9201            },
9202        )
9203        .await
9204        .unwrap();
9205        store::insert_entries(
9206            &state.db,
9207            feed,
9208            &[store::NewEntry {
9209                guid: "l-1".to_string(),
9210                url: Some("https://list.example/1".to_string()),
9211                title: Some("Article".to_string()),
9212                published: Some("2026-07-11T00:00:00Z".to_string()),
9213                ..Default::default()
9214            }],
9215            0,
9216        )
9217        .await
9218        .unwrap();
9219        store::replace_sub_refs(&state.db, did, &[feed])
9220            .await
9221            .unwrap();
9222        let entry_id = store::entries_for_feed(&state.db, did, feed).await.unwrap()[0].id;
9223
9224        let cookie = session_cookie(&state, did, None);
9225        let app = router(state.clone());
9226
9227        let resp = app
9228            .oneshot(
9229                Request::builder()
9230                    .method("POST")
9231                    .uri(format!("/entries/{entry_id}/read"))
9232                    .header(header::COOKIE, cookie)
9233                    .header("HX-Request", "true")
9234                    .header("content-type", "application/x-www-form-urlencoded")
9235                    .body(Body::from("read=true"))
9236                    .unwrap(),
9237            )
9238            .await
9239            .unwrap();
9240        assert_eq!(resp.status(), StatusCode::OK);
9241        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
9242            .await
9243            .unwrap();
9244        let html = String::from_utf8(bytes.to_vec()).unwrap();
9245        assert!(
9246            !html.contains("hx-swap-oob"),
9247            "list-view response must NOT be an OOB swap: {html}"
9248        );
9249        // **And it must actually BE the row.** The assertion above is satisfied
9250        // by an empty body, or by any response that simply omits the attribute —
9251        // so on its own it pins half a property and the name promises the other
9252        // half.
9253        assert!(
9254            html.contains(&format!("/entries/{entry_id}")),
9255            "the response is not the row for this entry: {html}",
9256        );
9257        assert!(
9258            html.contains("Article"),
9259            "the row rendered without its title: {html}",
9260        );
9261        // **The row comes back carrying read state. That is all this proves.**
9262        //
9263        // It does NOT prove the state was persisted: the handler renders
9264        // `Some(read)` from the form value, so making `mark_read` roll back
9265        // instead of commit fails 11 store tests and leaves this one green.
9266        //
9267        // It does not prove the OVERRIDE either, which an earlier version of
9268        // this comment claimed. Verified: changing the call site to
9269        // `build_entry_row(pool, &did, id, None)` — deleting the override
9270        // wholesale — keeps the whole suite green, because `mark_read` has
9271        // already persisted the same value two lines earlier, so reading it back
9272        // from the database produces an identical row.
9273        //
9274        // Distinguishing the two needs a case where the override and the stored
9275        // state DISAGREE, which this handler never produces: it writes the value
9276        // it then renders. Left as a known gap rather than described as covered.
9277        assert!(
9278            html.contains("is-read"),
9279            "the row came back without the read state it was just given: {html}",
9280        );
9281    }
9282
9283    // -----------------------------------------------------------------------
9284    // Rename parity (POST /subscriptions/{rkey}/rename)
9285    // -----------------------------------------------------------------------
9286
9287    /// **Autodiscovery cannot smuggle a non-http(s) URL into storage.**
9288    ///
9289    /// The add path gates the URL the user *typed*; the URL it *stores* is
9290    /// whatever `resolve_feed_url` returns, which for an HTML page is a
9291    /// publisher-controlled `<link rel="alternate">` href. Two layers stop
9292    /// that: `discover_feed` yields only http(s), and the add path re-checks
9293    /// storability on the resolved URL. This test pins the DISJUNCTION —
9294    /// each layer alone holds it, both removed fails it — driven through the
9295    /// real route against a real local server.
9296    ///
9297    /// **Why the fixture is `ftp://`, not `at://`.** This began as the
9298    /// at-URI bypass test from #164, and it was vacuous twice over. Handle
9299    /// form: once storage became DID-only the privacy classifier refused it
9300    /// at its own gate. DID form: `Url::parse` cannot read it (invalid port
9301    /// — the colons in the DID), so `discover_feed` drops it before either
9302    /// layer exists. An at:// link cannot come out of autodiscovery under
9303    /// ANY mutation of the layers, so no test through this route can pin
9304    /// them with one. `ftp://` reaches both. The at:// case is guaranteed by
9305    /// structure and pinned where it lives: `discover_skips_a_non_http_
9306    /// alternate` and the storability tests in `feed.rs`.
9307    #[tokio::test]
9308    async fn autodiscovery_cannot_smuggle_a_non_http_url_into_storage() {
9309        let did = "did:plc:autodiscovered";
9310        // Access granted, both caps disabled — the only gates left are the
9311        // two under test.
9312        let state = test_state_with_caps(did, 0, 0).await;
9313
9314        let page = r#"<!doctype html><html><head><title>Blog</title>
9315            <link rel="alternate" type="application/rss+xml" href="ftp://files.example/feed.xml">
9316            </head><body>hi</body></html>"#;
9317        let base = crate::net::tests::serve_body(page.as_bytes().to_vec()).await;
9318        let port: u16 = base
9319            .trim_end_matches('/')
9320            .rsplit(':')
9321            .next()
9322            .unwrap()
9323            .parse()
9324            .unwrap();
9325        crate::net::test_host_override(
9326            "autodiscover-ftp.test",
9327            std::net::SocketAddr::from(([127, 0, 0, 1], port)),
9328        );
9329
9330        let cookie = session_cookie(&state, did, None);
9331        let resp = router(state.clone())
9332            .oneshot(
9333                Request::builder()
9334                    .method("POST")
9335                    .uri("/subscriptions")
9336                    .header(header::COOKIE, cookie)
9337                    .header("content-type", "application/x-www-form-urlencoded")
9338                    .body(Body::from(format!(
9339                        "url=http://autodiscover-ftp.test:{port}/"
9340                    )))
9341                    .unwrap(),
9342            )
9343            .await
9344            .unwrap();
9345        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9346        let loc = resp
9347            .headers()
9348            .get(header::LOCATION)
9349            .unwrap()
9350            .to_str()
9351            .unwrap();
9352        assert_ne!(loc, "/login", "the test never reached the add path");
9353        assert_ne!(loc, "/", "the subscribe succeeded");
9354
9355        assert_eq!(
9356            store::count_feeds(&state.db).await.unwrap(),
9357            0,
9358            "a non-http(s) URL from autodiscovery was stored"
9359        );
9360        assert_eq!(
9361            store::count_subscriptions_for_did(&state.db, did)
9362                .await
9363                .unwrap(),
9364            0
9365        );
9366    }
9367
9368    /// A rename that points at a BRAND-NEW feed URL while the shared cache is at
9369    /// its global ceiling must be refused (capacity flash) and must NOT insert a
9370    /// new `feeds` row — parity with add_subscription's global-cap guard, so a
9371    /// rename loop can't inflate the shared cache past the cap.
9372    #[tokio::test]
9373    async fn rename_to_new_url_refused_at_global_feeds_cap() {
9374        let did = "did:plc:renamer4";
9375        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
9376        // Global cap 1; pre-fill it with one feed so headroom is 0.
9377        let state = test_state_with_sidecar_and(&[did], &sidecar, false, 1).await;
9378        store::upsert_feed(
9379            &state.db,
9380            &store::NewFeed {
9381                url: "https://existing.example/feed.xml".to_string(),
9382                ..Default::default()
9383            },
9384        )
9385        .await
9386        .unwrap();
9387        let before = store::count_feeds(&state.db).await.unwrap();
9388        assert_eq!(before, 1);
9389
9390        let cookie = session_cookie(&state, did, None);
9391        let resp = router(state.clone())
9392            .oneshot(
9393                Request::builder()
9394                    .method("POST")
9395                    .uri("/subscriptions/rk-keep/rename")
9396                    .header(header::COOKIE, cookie)
9397                    .header("content-type", "application/x-www-form-urlencoded")
9398                    // A URL not in the cache → would be a NEW feeds row.
9399                    .body(Body::from(
9400                        "url=https://brand-new.example/feed.xml&title=Renamed",
9401                    ))
9402                    .unwrap(),
9403            )
9404            .await
9405            .unwrap();
9406        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9407        let loc = resp
9408            .headers()
9409            .get(header::LOCATION)
9410            .unwrap()
9411            .to_str()
9412            .unwrap();
9413        assert!(
9414            loc.contains("feed%20capacity"),
9415            "expected the feed-capacity flash, got {loc}"
9416        );
9417        // No new feeds row was inserted, and nothing reached the PDS.
9418        assert_eq!(store::count_feeds(&state.db).await.unwrap(), before);
9419        assert!(
9420            puts.lock().unwrap().is_empty(),
9421            "a refused repoint reached the PDS"
9422        );
9423    }
9424
9425    /// A repoint to an EXISTING URL adds no row, so it is allowed even at the
9426    /// global cap (only new URLs are gated) — the other half of the guard.
9427    ///
9428    /// On the sidecar fake, so "allowed" means the put actually happened: the
9429    /// earlier harness had no sidecar, and this passed on a "could not reach
9430    /// your PDS" flash that merely was not the capacity one.
9431    #[tokio::test]
9432    async fn rename_to_existing_url_allowed_at_global_feeds_cap() {
9433        let did = "did:plc:renamer4";
9434        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
9435        let state = test_state_with_sidecar_and(&[did], &sidecar, false, 1).await;
9436        store::upsert_feed(
9437            &state.db,
9438            &store::NewFeed {
9439                url: "https://existing.example/feed.xml".to_string(),
9440                ..Default::default()
9441            },
9442        )
9443        .await
9444        .unwrap();
9445        let before = store::count_feeds(&state.db).await.unwrap();
9446
9447        let cookie = session_cookie(&state, did, None);
9448        let resp = router(state.clone())
9449            .oneshot(
9450                Request::builder()
9451                    .method("POST")
9452                    .uri("/subscriptions/rk-keep/rename")
9453                    .header(header::COOKIE, cookie)
9454                    .header("content-type", "application/x-www-form-urlencoded")
9455                    .body(Body::from(
9456                        "url=https://existing.example/feed.xml&title=Retitled",
9457                    ))
9458                    .unwrap(),
9459            )
9460            .await
9461            .unwrap();
9462        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9463        let loc = resp
9464            .headers()
9465            .get(header::LOCATION)
9466            .unwrap()
9467            .to_str()
9468            .unwrap();
9469        assert_eq!(loc, "/", "the repoint to a cached URL was refused: {loc}");
9470        assert_eq!(
9471            puts.lock().unwrap().len(),
9472            1,
9473            "the repoint did not reach the PDS"
9474        );
9475        assert_eq!(store::count_feeds(&state.db).await.unwrap(), before);
9476    }
9477
9478    /// A rename with a blank URL writes nothing anywhere.
9479    #[tokio::test]
9480    async fn rename_with_blank_url_writes_nothing() {
9481        let did = "did:plc:renamer3";
9482        let state = test_state_with_caps(did, 0, 0).await;
9483        let before = store::count_feeds(&state.db).await.unwrap();
9484        assert_eq!(before, 0);
9485
9486        let cookie = session_cookie(&state, did, None);
9487        let app = router(state.clone());
9488        let resp = app
9489            .oneshot(
9490                Request::builder()
9491                    .method("POST")
9492                    .uri("/subscriptions/rkey123/rename")
9493                    .header(header::COOKIE, cookie)
9494                    .header("content-type", "application/x-www-form-urlencoded")
9495                    // Whitespace-only URL trims to empty.
9496                    .body(Body::from("url=%20%20&title=Nope"))
9497                    .unwrap(),
9498            )
9499            .await
9500            .unwrap();
9501        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9502        assert_eq!(
9503            resp.headers()
9504                .get(header::LOCATION)
9505                .unwrap()
9506                .to_str()
9507                .unwrap(),
9508            "/",
9509        );
9510        // Nothing was cached.
9511        assert_eq!(
9512            store::count_feeds(&state.db).await.unwrap(),
9513            0,
9514            "blank-URL rename wrote a junk feeds row"
9515        );
9516    }
9517
9518    /// A sidecar mock that serves ONE existing subscription record and captures
9519    /// every `put` body a rename produces.
9520    ///
9521    /// **Reads to `content-length` rather than taking one `read`.** A single
9522    /// read gets whatever one segment carried; if the head and body land
9523    /// separately the capture holds no record and every field assertion below
9524    /// passes for the wrong reason. Each captured body must also mention the
9525    /// collection, so an empty capture fails loudly instead of quietly.
9526    async fn spawn_rename_sidecar(
9527        existing: serde_json::Value,
9528    ) -> (String, std::sync::Arc<std::sync::Mutex<Vec<String>>>) {
9529        use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
9530        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9531        let addr = listener.local_addr().unwrap();
9532        let puts = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
9533        let sink = puts.clone();
9534        tokio::spawn(async move {
9535            loop {
9536                let Ok((mut sock, _)) = listener.accept().await else {
9537                    break;
9538                };
9539                let mut raw: Vec<u8> = Vec::new();
9540                let mut chunk = [0u8; 4096];
9541                let body_text = loop {
9542                    let Ok(n) = sock.read(&mut chunk).await else {
9543                        break String::new();
9544                    };
9545                    if n == 0 {
9546                        break String::from_utf8_lossy(&raw).to_string();
9547                    }
9548                    raw.extend_from_slice(&chunk[..n]);
9549                    let Some(split) = raw.windows(4).position(|w| w == b"\r\n\r\n") else {
9550                        continue;
9551                    };
9552                    let (head, body) = raw.split_at(split + 4);
9553                    let want = String::from_utf8_lossy(head).lines().find_map(|l| {
9554                        let (k, v) = l.split_once(':')?;
9555                        k.eq_ignore_ascii_case("content-length")
9556                            .then(|| v.trim().parse::<usize>().ok())?
9557                    });
9558                    if want.is_none_or(|want| body.len() >= want) {
9559                        break String::from_utf8_lossy(body).to_string();
9560                    }
9561                };
9562
9563                // `"action":"put"` is the rename write; anything else is the read.
9564                let is_put = body_text.contains("\"action\":\"put\"");
9565                let data = if is_put {
9566                    sink.lock().unwrap().push(body_text.clone());
9567                    serde_json::json!({
9568                        "uri": "at://did:plc:x/community.lexicon.rss.subscription/rk-keep",
9569                        "cid": "bafyreiafter"
9570                    })
9571                } else {
9572                    serde_json::json!({ "records": [existing.clone()] })
9573                };
9574                let body = serde_json::json!({ "ok": true, "data": data }).to_string();
9575                let resp = format!(
9576                    "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
9577                    body.len(),
9578                    body
9579                );
9580                let _ = sock.write_all(resp.as_bytes()).await;
9581                let _ = sock.flush().await;
9582            }
9583        });
9584        (format!("http://{addr}"), puts)
9585    }
9586
9587    /// The existing record a rename must not destroy.
9588    fn seeded_subscription() -> serde_json::Value {
9589        serde_json::json!({
9590            "uri": "at://did:plc:renamer4/community.lexicon.rss.subscription/rk-keep",
9591            "cid": "bafyreibefore",
9592            "value": {
9593                "$type": "community.lexicon.rss.subscription",
9594                "url": "https://example.com/feed.xml",
9595                "title": "Old title",
9596                "siteUrl": "https://example.com/blog",
9597                "fetchHint": "hourly",
9598                "private": false,
9599                "createdAt": "2024-03-01T00:00:00.000Z"
9600            }
9601        })
9602    }
9603
9604    /// An existing standard.site subscription, as the 19 in production are:
9605    /// written before this reader refused the scheme, still in the repo.
9606    fn seeded_at_uri_subscription() -> serde_json::Value {
9607        seeded_subscription_with_url(AT_URI_SUB)
9608    }
9609    /// An existing subscription record at `rk-keep` with the given URL.
9610    fn seeded_subscription_with_url(url: &str) -> serde_json::Value {
9611        serde_json::json!({
9612            "uri": "at://did:plc:renamer5/community.lexicon.rss.subscription/rk-keep",
9613            "cid": "bafyreibefore",
9614            "value": {
9615                "$type": "community.lexicon.rss.subscription",
9616                "url": url,
9617                "title": "Old title",
9618                "private": false,
9619                "createdAt": "2024-03-01T00:00:00.000Z"
9620            }
9621        })
9622    }
9623    const AT_URI_SUB: &str =
9624        "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lab2c4d5e6f7g8h";
9625    const AT_URI_SUB_ENC: &str =
9626        "at%3A%2F%2Fdid%3Aplc%3Aohutz6x5acjmpuulp3x7wxxc%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h";
9627
9628    /// **Retitling an existing `at://` subscription must work with the flag off.**
9629    ///
9630    /// The storability guard was placed before the repo lookup, so it refused
9631    /// any rename whose URL is an at-URI — including a pure title or folder
9632    /// change on a record that already exists. On main that rename succeeded;
9633    /// the 19 production records would have become un-editable. The flag gates
9634    /// what may be STORED in the cache, not whether a reader may edit their own
9635    /// record: the PDS write goes through, the cache row is simply not created.
9636    #[tokio::test]
9637    async fn retitling_an_existing_at_uri_subscription_survives_the_flag_being_off() {
9638        let did = "did:plc:renamer5";
9639        let (sidecar, puts) = spawn_rename_sidecar(seeded_at_uri_subscription()).await;
9640        let state = test_state_with_sidecar(&[did], &sidecar).await;
9641        assert!(
9642            !state.config.standard_site,
9643            "the flag must be off for this test"
9644        );
9645        let cookie = session_cookie(&state, did, None);
9646        let resp = router(state.clone())
9647            .oneshot(
9648                Request::builder()
9649                    .method("POST")
9650                    .uri("/subscriptions/rk-keep/rename")
9651                    .header(header::COOKIE, cookie)
9652                    .header("content-type", "application/x-www-form-urlencoded")
9653                    .body(Body::from(format!("url={AT_URI_SUB_ENC}&title=New+title")))
9654                    .unwrap(),
9655            )
9656            .await
9657            .unwrap();
9658        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9659        let loc = resp
9660            .headers()
9661            .get(header::LOCATION)
9662            .unwrap()
9663            .to_str()
9664            .unwrap();
9665        assert_eq!(loc, "/", "the retitle was refused: {loc}");
9666
9667        let bodies = puts.lock().unwrap().clone();
9668        assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
9669        let sent: serde_json::Value = serde_json::from_str(&bodies[0]).expect("put body is JSON");
9670        assert_eq!(
9671            sent["record"]["title"], "New title",
9672            "the rename did not apply"
9673        );
9674        assert_eq!(
9675            sent["record"]["url"], AT_URI_SUB,
9676            "the rename changed the URL"
9677        );
9678
9679        // The flag still means what it says for the CACHE: no at:// row.
9680        let cached: i64 = store::count_unpollable_feeds(&state.db).await.unwrap();
9681        assert_eq!(cached, 0, "a retitle stored an at:// row with the flag off");
9682    }
9683
9684    /// **Repointing a subscription AT an `at://` URI is still refused with the
9685    /// flag off** — the half of the guard that has to survive the fix above.
9686    /// Nothing reaches the PDS and nothing reaches the cache.
9687    #[tokio::test]
9688    async fn repointing_a_subscription_at_an_at_uri_is_refused_with_the_flag_off() {
9689        let did = "did:plc:renamer4";
9690        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
9691        let state = test_state_with_sidecar(&[did], &sidecar).await;
9692        let cookie = session_cookie(&state, did, None);
9693        let resp = router(state.clone())
9694            .oneshot(
9695                Request::builder()
9696                    .method("POST")
9697                    .uri("/subscriptions/rk-keep/rename")
9698                    .header(header::COOKIE, cookie)
9699                    .header("content-type", "application/x-www-form-urlencoded")
9700                    .body(Body::from(format!("url={AT_URI_SUB_ENC}&title=Moved")))
9701                    .unwrap(),
9702            )
9703            .await
9704            .unwrap();
9705        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9706        let loc = resp
9707            .headers()
9708            .get(header::LOCATION)
9709            .unwrap()
9710            .to_str()
9711            .unwrap();
9712        assert!(loc.contains("flash="), "the repoint was not refused: {loc}");
9713        assert!(
9714            !loc.contains("Private"),
9715            "a storability refusal was reported as a privacy one: {loc}"
9716        );
9717        assert!(
9718            puts.lock().unwrap().is_empty(),
9719            "the repoint reached the PDS"
9720        );
9721        let cached: i64 = store::count_unpollable_feeds(&state.db).await.unwrap();
9722        assert_eq!(cached, 0);
9723    }
9724
9725    /// Posts a retitle of `rk-keep` with its URL unchanged; returns the
9726    /// redirect location.
9727    async fn retitle_unchanged(state: &AppState, did: &str, url_enc: &str) -> String {
9728        let cookie = session_cookie(state, did, None);
9729        let resp = router(state.clone())
9730            .oneshot(
9731                Request::builder()
9732                    .method("POST")
9733                    .uri("/subscriptions/rk-keep/rename")
9734                    .header(header::COOKIE, cookie)
9735                    .header("content-type", "application/x-www-form-urlencoded")
9736                    .body(Body::from(format!("url={url_enc}&title=New+title")))
9737                    .unwrap(),
9738            )
9739            .await
9740            .unwrap();
9741        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9742        resp.headers()
9743            .get(header::LOCATION)
9744            .unwrap()
9745            .to_str()
9746            .unwrap()
9747            .to_string()
9748    }
9749
9750    /// **The privacy gate has the same ordering bug the storable gate had.**
9751    ///
9752    /// Another client can write a subscription whose URL is an at-URI that is
9753    /// not a well-formed publication URI at all — a feed generator, say. On
9754    /// main a retitle of it succeeded (the DID form fails `Url::parse`, which
9755    /// the classifier reads as `Public`). The narrowed at:// arm now fails
9756    /// closed as `Private` for it, and the gate ran before `url_changed` was
9757    /// known — so the record became un-editable, with a flash claiming it "was
9758    /// not saved or sent anywhere". Both gates now apply to a repoint only.
9759    #[tokio::test]
9760    async fn retitling_an_existing_at_uri_record_that_is_not_a_publication_survives() {
9761        let did = "did:plc:renamer5";
9762        let other = "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/app.bsky.feed.generator/whats-hot";
9763        let other_enc =
9764            "at%3A%2F%2Fdid%3Aplc%3Aohutz6x5acjmpuulp3x7wxxc%2Fapp.bsky.feed.generator%2Fwhats-hot";
9765        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription_with_url(other)).await;
9766        let state = test_state_with_sidecar(&[did], &sidecar).await;
9767        let loc = retitle_unchanged(&state, did, other_enc).await;
9768        assert_eq!(loc, "/", "the retitle was refused: {loc}");
9769        let bodies = puts.lock().unwrap().clone();
9770        assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
9771        let sent: serde_json::Value = serde_json::from_str(&bodies[0]).unwrap();
9772        assert_eq!(sent["record"]["title"], "New title");
9773        assert_eq!(sent["record"]["url"], other);
9774    }
9775
9776    /// **A repoint to a secret-bearing URL is still refused** — the half of
9777    /// the privacy gate that has to survive moving it behind `url_changed`.
9778    /// Found by mutation: with the gate deleted outright, nothing failed.
9779    #[tokio::test]
9780    async fn repointing_a_subscription_at_a_private_feed_is_refused() {
9781        let did = "did:plc:renamer4";
9782        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
9783        let state = test_state_with_sidecar(&[did], &sidecar).await;
9784        let cookie = session_cookie(&state, did, None);
9785        let resp = router(state.clone())
9786            .oneshot(
9787                Request::builder()
9788                    .method("POST")
9789                    .uri("/subscriptions/rk-keep/rename")
9790                    .header(header::COOKIE, cookie)
9791                    .header("content-type", "application/x-www-form-urlencoded")
9792                    .body(Body::from(
9793                        "url=https%3A%2F%2Fpaid.example%2Ffeed.xml%3Ftoken%3DZm9vYmFyc2VjcmV0dG9rZW4&title=Moved",
9794                    ))
9795                    .unwrap(),
9796            )
9797            .await
9798            .unwrap();
9799        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9800        let loc = resp
9801            .headers()
9802            .get(header::LOCATION)
9803            .unwrap()
9804            .to_str()
9805            .unwrap();
9806        assert!(
9807            loc.contains("Private"),
9808            "the private repoint was not refused: {loc}"
9809        );
9810        assert!(
9811            puts.lock().unwrap().is_empty(),
9812            "a secret-bearing URL reached the PDS"
9813        );
9814        // The repo's fixture token: opaque enough for the classifier, not a real
9815        // key shape (a Stripe-shaped fixture tripped the secret scanner — rightly).
9816        let leaked = "https://paid.example/feed.xml?token=Zm9vYmFyc2VjcmV0dG9rZW4";
9817        assert!(store::get_feed_by_url(&state.db, leaked)
9818            .await
9819            .unwrap()
9820            .is_none());
9821    }
9822
9823    /// **A retitle of a never-cached at:// subscription is not "at feed
9824    /// capacity".** The global-ceiling check keyed on "URL not in the cache",
9825    /// and an at:// record is never cached with the flag off — so at capacity,
9826    /// a pure retitle was refused for a row the handler would not insert. The
9827    /// check now runs once `url_changed` is known and only for a repoint.
9828    #[tokio::test]
9829    async fn retitling_an_uncached_at_uri_subscription_is_not_refused_at_feed_capacity() {
9830        let did = "did:plc:renamer5";
9831        let (sidecar, puts) = spawn_rename_sidecar(seeded_at_uri_subscription()).await;
9832        // Ceiling 1, and one real feed already fills it.
9833        let state = test_state_with_sidecar_and(&[did], &sidecar, false, 1).await;
9834        store::upsert_feed(
9835            &state.db,
9836            &store::NewFeed {
9837                url: "https://filler.example/feed.xml".to_string(),
9838                ..Default::default()
9839            },
9840        )
9841        .await
9842        .unwrap();
9843        let loc = retitle_unchanged(&state, did, AT_URI_SUB_ENC).await;
9844        assert_eq!(loc, "/", "the retitle was refused: {loc}");
9845        assert_eq!(
9846            puts.lock().unwrap().len(),
9847            1,
9848            "the retitle did not reach the PDS"
9849        );
9850        assert_eq!(
9851            store::count_feeds(&state.db).await.unwrap(),
9852            1,
9853            "a row was inserted"
9854        );
9855    }
9856
9857    /// POST `/subscriptions` with `url`, returning the redirect target.
9858    async fn subscribe(state: &AppState, did: &str, url_enc: &str) -> String {
9859        let cookie = session_cookie(state, did, None);
9860        let resp = router(state.clone())
9861            .oneshot(
9862                Request::builder()
9863                    .method("POST")
9864                    .uri("/subscriptions")
9865                    .header(header::COOKIE, cookie)
9866                    .header("content-type", "application/x-www-form-urlencoded")
9867                    .body(Body::from(format!("url={url_enc}")))
9868                    .unwrap(),
9869            )
9870            .await
9871            .unwrap();
9872        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9873        resp.headers()
9874            .get(header::LOCATION)
9875            .unwrap()
9876            .to_str()
9877            .unwrap()
9878            .to_string()
9879    }
9880
9881    /// A `com.atproto.identity.resolveHandle` that answers `did` for anything.
9882    async fn serve_resolver(did: &str) -> String {
9883        let base = crate::net::tests::serve_body(
9884            serde_json::json!({ "did": did }).to_string().into_bytes(),
9885        )
9886        .await;
9887        let port: u16 = base
9888            .trim_end_matches('/')
9889            .rsplit(':')
9890            .next()
9891            .unwrap()
9892            .parse()
9893            .unwrap();
9894        let host = format!("resolver-{port}.test");
9895        crate::net::test_host_override(&host, std::net::SocketAddr::from(([127, 0, 0, 1], port)));
9896        format!("http://{host}:{port}")
9897    }
9898
9899    fn with_config(mut state: AppState, f: impl FnOnce(&mut Config)) -> AppState {
9900        let mut config = (*state.config).clone();
9901        f(&mut config);
9902        state.config = std::sync::Arc::new(config);
9903        state
9904    }
9905
9906    /// **0.4.0 step 3: with the flag ON, a well-formed at:// paste is
9907    /// subscribed.** It was refused as unsupported while nothing could read a
9908    /// publication; the poller reads them now. Stored in DID form, as a
9909    /// `publication`, and written to the reader's PDS like any subscription.
9910    #[tokio::test]
9911    async fn a_well_formed_at_uri_paste_is_subscribed_with_the_flag_on() {
9912        let did = "did:plc:renamer5";
9913        let (sidecar, log) = spawn_logging_sidecar().await;
9914        let state = with_config(
9915            test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
9916            |c| {
9917                c.oauth.plc_directory = "http://plc.nowhere.invalid".into();
9918            },
9919        );
9920        let loc = subscribe(&state, did, AT_URI_SUB_ENC).await;
9921        assert_eq!(loc, "/", "the paste was refused: {loc}");
9922        let row = store::get_feed_by_url(&state.db, AT_URI_SUB)
9923            .await
9924            .unwrap()
9925            .expect("no feed row");
9926        assert_eq!(feed::FeedKind::of(&row.url), feed::FeedKind::Publication);
9927        let sent = log.lock().unwrap().join("\n");
9928        assert!(
9929            sent.contains(AT_URI_SUB),
9930            "the subscription was not written to the PDS: {sent}"
9931        );
9932    }
9933
9934    /// **A0 through the form** — the 0.4.0 exit test, end to end: a reader
9935    /// pastes a publication, it is stored and written to their PDS, and the
9936    /// first poll — the one subscribing runs at once — stores its documents.
9937    #[tokio::test]
9938    async fn a0_subscribing_from_the_form_delivers_entries() {
9939        let did = "did:plc:renamer5";
9940        let author = "did:plc:ohutz6x5acjmpuulp3x7wxxc";
9941        let site = AT_URI_SUB;
9942        let (plc, _) = crate::standard_site::tests::serve_repo(
9943            author,
9944            vec![
9945                (
9946                    lexicon::nsid::STANDARD_PUBLICATION,
9947                    "3lab2c4d5e6f7g8h",
9948                    serde_json::json!({ "name": "A0 Journal", "url": "https://a0.example" }),
9949                ),
9950                (
9951                    lexicon::nsid::STANDARD_DOCUMENT,
9952                    "3l2a0frmaaa2a",
9953                    serde_json::json!({ "title": "From the form", "path": "/f",
9954                        "publishedAt": "2026-07-11T00:00:00Z", "site": site }),
9955                ),
9956            ],
9957        )
9958        .await;
9959        let (sidecar, _log) = spawn_logging_sidecar().await;
9960        let state = with_config(
9961            test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
9962            |c| {
9963                c.oauth.plc_directory = plc;
9964            },
9965        );
9966        assert_eq!(subscribe(&state, did, AT_URI_SUB_ENC).await, "/");
9967        let row = store::get_feed_by_url(&state.db, site)
9968            .await
9969            .unwrap()
9970            .unwrap();
9971        let titles: Vec<String> = sqlx::query_scalar("SELECT title FROM entries WHERE feed_id = ?")
9972            .bind(row.id)
9973            .fetch_all(&state.db)
9974            .await
9975            .unwrap();
9976        assert_eq!(
9977            titles,
9978            vec!["From the form".to_string()],
9979            "the first poll stored nothing"
9980        );
9981        assert_eq!(row.title.as_deref(), Some("A0 Journal"));
9982    }
9983
9984    /// A handle-form paste is resolved to the DID before it is stored: a
9985    /// handle is a mutable name, and `feeds.url` is keyed on identity.
9986    #[tokio::test]
9987    async fn a_handle_form_paste_is_stored_by_its_did() {
9988        let did = "did:plc:renamer5";
9989        let author = "did:plc:ohutz6x5acjmpuulp3x7wxxc";
9990        let (sidecar, _log) = spawn_logging_sidecar().await;
9991        let resolver = serve_resolver(author).await;
9992        let state = with_config(
9993            test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
9994            |c| {
9995                c.resolver_base = resolver;
9996                c.oauth.plc_directory = "http://plc.nowhere.invalid".into();
9997            },
9998        );
9999        let loc = subscribe(
10000            &state,
10001            did,
10002            "at%3A%2F%2Falice.example.com%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
10003        )
10004        .await;
10005        assert_eq!(loc, "/", "the paste was refused: {loc}");
10006        assert!(
10007            store::get_feed_by_url(&state.db, AT_URI_SUB)
10008                .await
10009                .unwrap()
10010                .is_some(),
10011            "not stored by its DID"
10012        );
10013        assert_eq!(
10014            store::count_feeds(&state.db).await.unwrap(),
10015            1,
10016            "the handle form was stored too"
10017        );
10018    }
10019
10020    /// A resolver answering `did` that counts how often it was asked.
10021    async fn serve_counting_resolver(
10022        did: &str,
10023    ) -> (String, std::sync::Arc<std::sync::atomic::AtomicUsize>) {
10024        let (base, hits) = crate::net::tests::serve_body_counted(
10025            serde_json::json!({ "did": did }).to_string().into_bytes(),
10026        )
10027        .await;
10028        let port: u16 = base
10029            .trim_end_matches('/')
10030            .rsplit(':')
10031            .next()
10032            .unwrap()
10033            .parse()
10034            .unwrap();
10035        let host = format!("counting-resolver-{port}.test");
10036        crate::net::test_host_override(&host, std::net::SocketAddr::from(([127, 0, 0, 1], port)));
10037        (format!("http://{host}:{port}"), hits)
10038    }
10039
10040    /// Review of #230: the per-DID cap is documented as checked "BEFORE any
10041    /// fetch/resolve so an over-cap account can't even trigger an outbound
10042    /// request" — a handle paste resolved the handle first.
10043    #[tokio::test]
10044    async fn an_over_cap_handle_paste_makes_no_outbound_request() {
10045        let did = "did:plc:renamer5";
10046        let (sidecar, _log) = spawn_logging_sidecar().await;
10047        let (resolver, hits) = serve_counting_resolver("did:plc:ohutz6x5acjmpuulp3x7wxxc").await;
10048        let state = with_config(
10049            test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
10050            |c| {
10051                c.resolver_base = resolver;
10052                c.max_subs_per_did = 1;
10053            },
10054        );
10055        let feed_id = store::upsert_feed(
10056            &state.db,
10057            &store::NewFeed {
10058                url: "https://already.example/feed.xml".into(),
10059                ..Default::default()
10060            },
10061        )
10062        .await
10063        .unwrap();
10064        store::replace_sub_refs(&state.db, did, &[feed_id])
10065            .await
10066            .unwrap();
10067        let loc = subscribe(
10068            &state,
10069            did,
10070            "at%3A%2F%2Falice.example.com%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
10071        )
10072        .await;
10073        assert!(
10074            loc.contains("Subscription%20limit"),
10075            "expected the cap flash: {loc}"
10076        );
10077        assert_eq!(
10078            hits.load(std::sync::atomic::Ordering::SeqCst),
10079            0,
10080            "an over-cap paste resolved a handle"
10081        );
10082    }
10083
10084    /// Review of #230: an authority that is neither a valid DID nor a valid
10085    /// handle — `did:plc:TOOSHORT`, an uppercase DID — went to the resolver as
10086    /// a "handle". It is unsupported, and asks nobody anything.
10087    #[tokio::test]
10088    async fn a_malformed_did_paste_is_unsupported_with_the_flag_on() {
10089        let did = "did:plc:renamer5";
10090        let (sidecar, _log) = spawn_logging_sidecar().await;
10091        let (resolver, hits) = serve_counting_resolver("did:plc:ohutz6x5acjmpuulp3x7wxxc").await;
10092        let state = with_config(
10093            test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
10094            |c| {
10095                c.resolver_base = resolver;
10096            },
10097        );
10098        for authority in [
10099            "did%3Aplc%3ATOOSHORT",
10100            "did%3Aplc%3AOHUTZ6X5ACJMPUULP3X7WXXC",
10101            "bad%0Ahandle.example",
10102        ] {
10103            let loc = subscribe(
10104                &state,
10105                did,
10106                &format!("at%3A%2F%2F{authority}%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h"),
10107            )
10108            .await;
10109            assert!(
10110                loc.contains("kind%20of%20feed"),
10111                "{authority}: expected the unsupported flash: {loc}"
10112            );
10113        }
10114        assert_eq!(
10115            hits.load(std::sync::atomic::Ordering::SeqCst),
10116            0,
10117            "a malformed authority reached the resolver"
10118        );
10119        assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
10120    }
10121
10122    /// A handle that does not resolve is refused, and nothing is stored.
10123    #[tokio::test]
10124    async fn an_unresolvable_handle_paste_is_refused() {
10125        let did = "did:plc:renamer5";
10126        let (sidecar, _log) = spawn_logging_sidecar().await;
10127        let state = with_config(
10128            test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
10129            |c| {
10130                c.resolver_base = "http://resolver.nowhere.invalid".into();
10131            },
10132        );
10133        let loc = subscribe(
10134            &state,
10135            did,
10136            "at%3A%2F%2Fnobody.example.com%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
10137        )
10138        .await;
10139        assert!(
10140            loc.contains("resolve%20the%20handle"),
10141            "expected the unresolvable-handle flash: {loc}"
10142        );
10143        assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
10144    }
10145
10146    /// An at:// URI that is not a publication is refused, flag on or off.
10147    #[tokio::test]
10148    async fn a_non_publication_at_uri_paste_is_refused() {
10149        let did = "did:plc:renamer5";
10150        let (sidecar, _log) = spawn_logging_sidecar().await;
10151        let state = test_state_with_sidecar_and(&[did], &sidecar, true, 0).await;
10152        let loc = subscribe(
10153            &state,
10154            did,
10155            "at%3A%2F%2Fdid%3Aplc%3Aohutz6x5acjmpuulp3x7wxxc%2Fapp.bsky.feed.post%2F3lab2c4d5e6f7g8h",
10156        )
10157        .await;
10158        assert!(
10159            loc.contains("kind%20of%20feed"),
10160            "expected the unsupported flash: {loc}"
10161        );
10162        assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
10163    }
10164
10165    /// A mixed-case scheme is canonicalised at input, not refused and not
10166    /// stored as a second spelling of the same publication.
10167    #[tokio::test]
10168    async fn a_mixed_case_at_scheme_paste_is_stored_canonically() {
10169        let did = "did:plc:renamer5";
10170        let (sidecar, _log) = spawn_logging_sidecar().await;
10171        let state = with_config(
10172            test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
10173            |c| {
10174                c.oauth.plc_directory = "http://plc.nowhere.invalid".into();
10175            },
10176        );
10177        let loc = subscribe(&state, did, &AT_URI_SUB_ENC.replacen("at", "At", 1)).await;
10178        assert_eq!(loc, "/", "the paste was refused: {loc}");
10179        assert!(store::get_feed_by_url(&state.db, AT_URI_SUB)
10180            .await
10181            .unwrap()
10182            .is_some());
10183    }
10184
10185    /// **With the flag ON, an OPML at:// entry is stored.** The one storage
10186    /// path that is meant to work today, asserted with the flag actually on.
10187    #[tokio::test]
10188    async fn opml_import_stores_an_at_uri_entry_with_the_flag_on() {
10189        let did = "did:plc:renamer5";
10190        let (sidecar, _puts) = spawn_rename_sidecar(seeded_subscription()).await;
10191        let state = test_state_with_sidecar_and(&[did], &sidecar, true, 0).await;
10192        let opml = format!(
10193            "<?xml version=\"1.0\"?>\n<opml version=\"2.0\"><head><title>t</title></head><body>\n\
10194             <outline type=\"rss\" text=\"Real\" xmlUrl=\"https://real.example/feed.xml\"/>\n\
10195             <outline type=\"rss\" text=\"Pub\" xmlUrl=\"{AT_URI_SUB}\"/>\n\
10196             </body></opml>"
10197        );
10198        let (ct, body) = opml_multipart(opml.as_bytes());
10199        let cookie = session_cookie(&state, did, None);
10200        let resp = router(state.clone())
10201            .oneshot(
10202                Request::builder()
10203                    .method("POST")
10204                    .uri("/opml")
10205                    .header(header::COOKIE, cookie)
10206                    .header("content-type", ct)
10207                    .body(Body::from(body))
10208                    .unwrap(),
10209            )
10210            .await
10211            .unwrap();
10212        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10213        let loc = resp
10214            .headers()
10215            .get(header::LOCATION)
10216            .unwrap()
10217            .to_str()
10218            .unwrap();
10219        assert!(
10220            loc.contains("Imported%202%20feeds"),
10221            "unexpected flash: {loc}"
10222        );
10223        assert!(
10224            !loc.contains("skipped"),
10225            "the at:// entry was skipped with the flag on: {loc}"
10226        );
10227        let stored = store::get_feed_by_url(&state.db, AT_URI_SUB).await.unwrap();
10228        assert!(
10229            stored.is_some(),
10230            "the at:// entry was not stored with the flag on"
10231        );
10232    }
10233
10234    /// **A retitle must not cache a secret-bearing URL.** Moving the privacy
10235    /// gate behind `url_changed` was right for the PDS write — the record is
10236    /// the reader's — but the cache write was gated only on `storable`, which
10237    /// any http(s) URL is. So a retitle of a record another client wrote with
10238    /// a tokened feed URL inserted that URL into the shared `feeds` table,
10239    /// where the poller would fail it every cycle and print it on the admin
10240    /// page. main refused the whole rename; this keeps the record editable and
10241    /// the cache clean, as `resolve_subscriptions` already does for the same
10242    /// record.
10243    #[tokio::test]
10244    async fn retitling_a_secret_bearing_record_does_not_cache_its_url() {
10245        let did = "did:plc:renamer5";
10246        let tokened = "https://www.patreon.com/rss/author?auth=Zm9vYmFyc2VjcmV0dG9rZW4";
10247        let tokened_enc =
10248            "https%3A%2F%2Fwww.patreon.com%2Frss%2Fauthor%3Fauth%3DZm9vYmFyc2VjcmV0dG9rZW4";
10249        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription_with_url(tokened)).await;
10250        let state = test_state_with_sidecar(&[did], &sidecar).await;
10251        let loc = retitle_unchanged(&state, did, tokened_enc).await;
10252        assert_eq!(loc, "/", "the retitle was refused: {loc}");
10253        assert_eq!(
10254            puts.lock().unwrap().len(),
10255            1,
10256            "the retitle did not reach the PDS"
10257        );
10258        assert!(
10259            store::get_feed_by_url(&state.db, tokened)
10260                .await
10261                .unwrap()
10262                .is_none(),
10263            "a secret-bearing URL was written to the shared cache by a retitle"
10264        );
10265    }
10266
10267    /// **On a repoint, storability is decided before privacy and capacity** —
10268    /// the same ordering the add path got. A malformed at:// target drew the
10269    /// private/paid flash, and at capacity a well-formed one drew "try again
10270    /// later" for a URL that can never be accepted with the flag off.
10271    #[tokio::test]
10272    async fn repointing_at_a_malformed_at_uri_is_refused_as_unsupported() {
10273        let did = "did:plc:renamer4";
10274        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
10275        let state = test_state_with_sidecar(&[did], &sidecar).await;
10276        let cookie = session_cookie(&state, did, None);
10277        let resp = router(state.clone())
10278            .oneshot(
10279                Request::builder()
10280                    .method("POST")
10281                    .uri("/subscriptions/rk-keep/rename")
10282                    .header(header::COOKIE, cookie)
10283                    .header("content-type", "application/x-www-form-urlencoded")
10284                    .body(Body::from(
10285                        "url=at%3A%2F%2Fdid%3Aplc%3ATOOSHORT%2Fsite.standard.publication%2F3lab&title=Moved",
10286                    ))
10287                    .unwrap(),
10288            )
10289            .await
10290            .unwrap();
10291        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10292        let loc = resp
10293            .headers()
10294            .get(header::LOCATION)
10295            .unwrap()
10296            .to_str()
10297            .unwrap();
10298        assert!(
10299            loc.contains("kind%20of%20feed"),
10300            "expected the unsupported flash: {loc}"
10301        );
10302        assert!(
10303            !loc.contains("Private"),
10304            "a typo was reported as a paid feed: {loc}"
10305        );
10306        assert!(puts.lock().unwrap().is_empty());
10307    }
10308
10309    #[tokio::test]
10310    async fn repointing_at_an_at_uri_at_capacity_is_refused_as_unsupported_not_capacity() {
10311        let did = "did:plc:renamer4";
10312        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
10313        let state = test_state_with_sidecar_and(&[did], &sidecar, false, 1).await;
10314        store::upsert_feed(
10315            &state.db,
10316            &store::NewFeed {
10317                url: "https://filler.example/feed.xml".to_string(),
10318                ..Default::default()
10319            },
10320        )
10321        .await
10322        .unwrap();
10323        let cookie = session_cookie(&state, did, None);
10324        let resp = router(state.clone())
10325            .oneshot(
10326                Request::builder()
10327                    .method("POST")
10328                    .uri("/subscriptions/rk-keep/rename")
10329                    .header(header::COOKIE, cookie)
10330                    .header("content-type", "application/x-www-form-urlencoded")
10331                    .body(Body::from(format!("url={AT_URI_SUB_ENC}&title=Moved")))
10332                    .unwrap(),
10333            )
10334            .await
10335            .unwrap();
10336        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10337        let loc = resp
10338            .headers()
10339            .get(header::LOCATION)
10340            .unwrap()
10341            .to_str()
10342            .unwrap();
10343        assert!(
10344            loc.contains("kind%20of%20feed"),
10345            "expected the unsupported flash: {loc}"
10346        );
10347        assert!(
10348            !loc.contains("capacity"),
10349            "an unacceptable URL was reported as a capacity problem: {loc}"
10350        );
10351        assert!(puts.lock().unwrap().is_empty());
10352    }
10353
10354    /// **`url_changed` compares like for like.** The form value is trimmed;
10355    /// the record's URL was compared raw, so a record another client wrote
10356    /// with a trailing space read as a repoint on every retitle and re-armed
10357    /// every gate — including the one that made an at:// record un-editable.
10358    #[tokio::test]
10359    async fn retitling_a_record_whose_url_carries_whitespace_is_not_a_repoint() {
10360        let did = "did:plc:renamer5";
10361        let padded = format!("{AT_URI_SUB} ");
10362        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription_with_url(&padded)).await;
10363        let state = test_state_with_sidecar(&[did], &sidecar).await;
10364        // The manage row posts the record's URL verbatim, padding included.
10365        let loc = retitle_unchanged(&state, did, &format!("{AT_URI_SUB_ENC}%20")).await;
10366        assert_eq!(
10367            loc, "/",
10368            "the retitle was treated as a repoint and refused: {loc}"
10369        );
10370        let bodies = puts.lock().unwrap().clone();
10371        assert_eq!(bodies.len(), 1);
10372        let sent: serde_json::Value = serde_json::from_str(&bodies[0]).unwrap();
10373        assert_eq!(
10374            sent["record"]["url"], AT_URI_SUB,
10375            "the padding was not normalised away"
10376        );
10377    }
10378
10379    /// **A retitle inserts no cache row.** The ceiling is checked on a repoint
10380    /// only, so the trailing upsert must not create a row for an unchanged URL
10381    /// that has none — with the flag on and the cache full, each retitle of a
10382    /// never-cached at:// record was a row past the cap. An existing row still
10383    /// gets its title kept in step.
10384    #[tokio::test]
10385    async fn retitling_an_uncached_record_at_capacity_inserts_no_row() {
10386        let did = "did:plc:renamer5";
10387        let (sidecar, puts) = spawn_rename_sidecar(seeded_at_uri_subscription()).await;
10388        let state = test_state_with_sidecar_and(&[did], &sidecar, true, 1).await;
10389        store::upsert_feed(
10390            &state.db,
10391            &store::NewFeed {
10392                url: "https://filler.example/feed.xml".to_string(),
10393                ..Default::default()
10394            },
10395        )
10396        .await
10397        .unwrap();
10398        let loc = retitle_unchanged(&state, did, AT_URI_SUB_ENC).await;
10399        assert_eq!(loc, "/", "the retitle was refused: {loc}");
10400        assert_eq!(puts.lock().unwrap().len(), 1);
10401        assert_eq!(
10402            store::count_feeds(&state.db).await.unwrap(),
10403            1,
10404            "a retitle inserted a cache row past the ceiling"
10405        );
10406    }
10407
10408    /// **The add path's at:// pre-check is about the MESSAGE, so it is
10409    /// case-insensitive.** `Url::parse` folds the scheme, so `AT://…` skipped
10410    /// the pre-check and the classifier's at:// arm alike, parsed as `at`, and
10411    /// tripped the secret heuristic on the rkey — the private/paid flash the
10412    /// pre-check exists to avoid. Storage stays case-sensitive; this does not
10413    /// touch it.
10414    #[tokio::test]
10415    async fn an_uppercase_at_scheme_paste_is_refused_as_unsupported() {
10416        let did = "did:plc:typoist";
10417        let state = test_state_with_caps(did, 0, 0).await;
10418        let cookie = session_cookie(&state, did, None);
10419        let resp = router(state.clone())
10420            .oneshot(
10421                Request::builder()
10422                    .method("POST")
10423                    .uri("/subscriptions")
10424                    .header(header::COOKIE, cookie)
10425                    .header("content-type", "application/x-www-form-urlencoded")
10426                    .body(Body::from(
10427                        "url=AT%3A%2F%2Falice.example.com%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
10428                    ))
10429                    .unwrap(),
10430            )
10431            .await
10432            .unwrap();
10433        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10434        let loc = resp
10435            .headers()
10436            .get(header::LOCATION)
10437            .unwrap()
10438            .to_str()
10439            .unwrap();
10440        assert!(
10441            loc.contains("kind%20of%20feed"),
10442            "expected the unsupported flash: {loc}"
10443        );
10444        assert!(!loc.contains("Private"), "reported as a paid feed: {loc}");
10445    }
10446
10447    /// **A rename must not destroy the fields the form never carries.**
10448    ///
10449    /// `update_subscription` is a `putRecord` — the WHOLE record is replaced, per
10450    /// its own doc. The handler built a fresh `Subscription::new(url, now())`, so
10451    /// every field absent from `templates/manage_row.html` (which posts only
10452    /// `url`, `title`, `folder`) was written back as its default:
10453    ///
10454    /// | field | before | after |
10455    /// |---|---|---|
10456    /// | `siteUrl` | whatever the feed advertised | gone |
10457    /// | `fetchHint` | as set | gone |
10458    /// | `private` | as set | gone |
10459    /// | `createdAt` | original subscribe time | reset to now |
10460    ///
10461    /// `createdAt` is the worst of the four: it is the sort key for "when did I
10462    /// subscribe", it is unrecoverable once overwritten, and nothing in the UI
10463    /// tells the reader it moved.
10464    ///
10465    /// Asserted on the BYTES THE SIDECAR RECEIVES, not on a `Subscription` built
10466    /// in the test — the record only becomes wrong on the way out, so checking
10467    /// the value we passed in would pass just as happily with the fix removed.
10468    #[tokio::test]
10469    async fn renaming_preserves_the_fields_the_form_never_carries() {
10470        let did = "did:plc:renamer4";
10471        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
10472        let state = test_state_with_sidecar(&[did], &sidecar).await;
10473        let cookie = session_cookie(&state, did, None);
10474
10475        let resp = router(state.clone())
10476            .oneshot(
10477                Request::builder()
10478                    .method("POST")
10479                    .uri("/subscriptions/rk-keep/rename")
10480                    .header(header::COOKIE, cookie)
10481                    .header("content-type", "application/x-www-form-urlencoded")
10482                    // Exactly what the manage row posts: url, title, folder.
10483                    .body(Body::from(
10484                        "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=New+title&folder=Tech",
10485                    ))
10486                    .unwrap(),
10487            )
10488            .await
10489            .unwrap();
10490        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10491
10492        let bodies = puts.lock().unwrap().clone();
10493        assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
10494        let body = &bodies[0];
10495        // Anchors the negative assertions: an empty capture would satisfy them.
10496        assert!(
10497            body.contains("community.lexicon.rss.subscription"),
10498            "captured no usable put body: {body:?}"
10499        );
10500
10501        let sent: serde_json::Value = serde_json::from_str(body).expect("put body is JSON");
10502        let record = &sent["record"];
10503
10504        // What the form DID carry must be applied.
10505        assert_eq!(record["title"], "New title", "the rename did not apply");
10506        assert_eq!(record["folder"], "Tech", "the re-folder did not apply");
10507
10508        // What the form did NOT carry must survive.
10509        assert_eq!(
10510            record["createdAt"], "2024-03-01T00:00:00.000Z",
10511            "the rename reset createdAt — the reader's subscribe time is gone \
10512             from their own repo, and nothing told them"
10513        );
10514        assert_eq!(
10515            record["siteUrl"], "https://example.com/blog",
10516            "the rename erased siteUrl"
10517        );
10518        assert_eq!(record["fetchHint"], "hourly", "the rename erased fetchHint");
10519        assert_eq!(record["private"], false, "the rename erased private");
10520    }
10521
10522    /// **Repointing at a different feed drops that feed's properties, but not
10523    /// the subscription's.**
10524    ///
10525    /// `siteUrl` and `fetchHint` describe the feed the subscription points at,
10526    /// so carrying them onto a different URL would leave a site link for the old
10527    /// feed hanging off the new one. `createdAt` and `private` are properties of
10528    /// the SUBSCRIPTION and survive a repoint — the reader subscribed when they
10529    /// subscribed, whatever the URL was later corrected to.
10530    #[tokio::test]
10531    async fn repointing_a_feed_drops_the_old_feeds_properties_but_keeps_the_subscriptions() {
10532        let did = "did:plc:renamer4";
10533        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
10534        let state = test_state_with_sidecar(&[did], &sidecar).await;
10535        let cookie = session_cookie(&state, did, None);
10536
10537        let resp = router(state.clone())
10538            .oneshot(
10539                Request::builder()
10540                    .method("POST")
10541                    .uri("/subscriptions/rk-keep/rename")
10542                    .header(header::COOKIE, cookie)
10543                    .header("content-type", "application/x-www-form-urlencoded")
10544                    // A DIFFERENT feed URL from the seeded record.
10545                    .body(Body::from(
10546                        "url=https%3A%2F%2Fother.example%2Ffeed.xml&title=Repointed",
10547                    ))
10548                    .unwrap(),
10549            )
10550            .await
10551            .unwrap();
10552        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10553
10554        let bodies = puts.lock().unwrap().clone();
10555        assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
10556        assert!(
10557            bodies[0].contains("community.lexicon.rss.subscription"),
10558            "captured no usable put body: {:?}",
10559            bodies[0]
10560        );
10561        let sent: serde_json::Value = serde_json::from_str(&bodies[0]).expect("put body is JSON");
10562        let record = &sent["record"];
10563
10564        assert_eq!(record["url"], "https://other.example/feed.xml");
10565        // The old feed's properties are gone rather than misattributed.
10566        assert!(
10567            record.get("siteUrl").is_none() || record["siteUrl"].is_null(),
10568            "the old feed's site link followed the subscription to a new feed: {record}"
10569        );
10570        assert!(
10571            record.get("fetchHint").is_none() || record["fetchHint"].is_null(),
10572            "the old feed's fetch hint followed the subscription to a new feed: {record}"
10573        );
10574        // The subscription's own properties survive.
10575        assert_eq!(
10576            record["createdAt"], "2024-03-01T00:00:00.000Z",
10577            "a repoint is still not a new subscription; createdAt must not move"
10578        );
10579        assert_eq!(record["private"], false, "the repoint erased private");
10580    }
10581
10582    /// **A rename against an rkey that is not in the repo writes NOTHING.**
10583    ///
10584    /// `update_subscription` is a `putRecord`, which CREATES the record when the
10585    /// rkey does not exist — with whatever `createdAt` we hand it. So without
10586    /// this refusal a rename against a stale or wrong rkey manufactures a
10587    /// subscription dated today, which is the bug this whole change exists to
10588    /// fix, arriving by a different door.
10589    ///
10590    /// The guard was untested when first written: removing it left all 733 tests
10591    /// green. An untested guard against the exact defect being fixed is how the
10592    /// two previous rounds of this problem got through.
10593    #[tokio::test]
10594    async fn renaming_an_unknown_rkey_writes_nothing() {
10595        let did = "did:plc:renamer4";
10596        // The sidecar serves exactly one record, at rkey `rk-keep`.
10597        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
10598        let state = test_state_with_sidecar(&[did], &sidecar).await;
10599        let cookie = session_cookie(&state, did, None);
10600
10601        let resp = router(state.clone())
10602            .oneshot(
10603                Request::builder()
10604                    .method("POST")
10605                    // ...and this is not it.
10606                    .uri("/subscriptions/rk-does-not-exist/rename")
10607                    .header(header::COOKIE, cookie)
10608                    .header("content-type", "application/x-www-form-urlencoded")
10609                    .body(Body::from(
10610                        "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=Ghost",
10611                    ))
10612                    .unwrap(),
10613            )
10614            .await
10615            .unwrap();
10616
10617        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10618        let loc = resp
10619            .headers()
10620            .get(header::LOCATION)
10621            .unwrap()
10622            .to_str()
10623            .unwrap();
10624        assert!(
10625            loc.contains("flash="),
10626            "an unknown rkey redirected as though the rename had worked: {loc}"
10627        );
10628        assert!(
10629            puts.lock().unwrap().is_empty(),
10630            "a rename against an unknown rkey wrote a record — putRecord would \
10631             CREATE it, dated today: {:?}",
10632            puts.lock().unwrap()
10633        );
10634    }
10635
10636    /// **A `site_url` the client actually sends is applied, not dropped.**
10637    ///
10638    /// `templates/manage_row.html` does not post this field, so it is tempting
10639    /// to read the arm that handles it as dead code. It is not:
10640    /// `RenameSubForm` carries `site_url`, so a hand-crafted POST reaches it
10641    /// today. Discarding the value instead of applying it left all 733 tests
10642    /// green.
10643    ///
10644    /// The value is scheme-checked on the way out by the repo-boundary vet, so
10645    /// this is a coverage gap rather than an exposure — but an untested path
10646    /// that writes a URL into the reader's PDS should not stay untested.
10647    #[tokio::test]
10648    async fn a_client_supplied_site_url_reaches_the_record() {
10649        let did = "did:plc:renamer4";
10650        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
10651        let state = test_state_with_sidecar(&[did], &sidecar).await;
10652        let cookie = session_cookie(&state, did, None);
10653
10654        let resp = router(state.clone())
10655            .oneshot(
10656                Request::builder()
10657                    .method("POST")
10658                    .uri("/subscriptions/rk-keep/rename")
10659                    .header(header::COOKIE, cookie)
10660                    .header("content-type", "application/x-www-form-urlencoded")
10661                    // Same feed URL, but carrying a site_url the manage row
10662                    // never sends.
10663                    .body(Body::from(
10664                        "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=Kept\
10665                         &site_url=https%3A%2F%2Ftyped.example%2Fsite",
10666                    ))
10667                    .unwrap(),
10668            )
10669            .await
10670            .unwrap();
10671        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10672
10673        let bodies = puts.lock().unwrap().clone();
10674        assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
10675        assert!(
10676            bodies[0].contains("community.lexicon.rss.subscription"),
10677            "captured no usable put body: {:?}",
10678            bodies[0]
10679        );
10680        let sent: serde_json::Value = serde_json::from_str(&bodies[0]).expect("put body is JSON");
10681        assert_eq!(
10682            sent["record"]["siteUrl"], "https://typed.example/site",
10683            "the client's siteUrl was dropped; the seeded record's survived instead"
10684        );
10685    }
10686
10687    /// **A rename whose read fails writes NOTHING.**
10688    ///
10689    /// This is the property most easily lost when someone later touches this
10690    /// handler: falling back to `Subscription::new` on a read error looks like
10691    /// graceful degradation and is in fact the original bug, reinstated on
10692    /// exactly the path where it is hardest to notice. The reader must be told
10693    /// instead.
10694    #[tokio::test]
10695    async fn a_rename_whose_read_fails_writes_nothing() {
10696        let did = "did:plc:renamer5";
10697        // A port that accepts nothing: the read cannot succeed.
10698        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
10699        let dead = format!("http://{}", listener.local_addr().unwrap());
10700        drop(listener);
10701
10702        let state = test_state_with_sidecar(&[did], &dead).await;
10703        let cookie = session_cookie(&state, did, None);
10704        let before = store::count_feeds(&state.db).await.unwrap();
10705
10706        let resp = router(state.clone())
10707            .oneshot(
10708                Request::builder()
10709                    .method("POST")
10710                    .uri("/subscriptions/rk-keep/rename")
10711                    .header(header::COOKIE, cookie)
10712                    .header("content-type", "application/x-www-form-urlencoded")
10713                    .body(Body::from(
10714                        "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=Doomed",
10715                    ))
10716                    .unwrap(),
10717            )
10718            .await
10719            .unwrap();
10720
10721        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10722        let loc = resp
10723            .headers()
10724            .get(header::LOCATION)
10725            .unwrap()
10726            .to_str()
10727            .unwrap();
10728        assert!(
10729            loc.contains("flash="),
10730            "a failed read redirected as though the rename had worked: {loc}"
10731        );
10732        assert_eq!(
10733            store::count_feeds(&state.db).await.unwrap(),
10734            before,
10735            "a rename that could not read the record still wrote to the cache"
10736        );
10737    }
10738
10739    /// Folder pre-selection regression: the manage rename row must mark the
10740    /// feed's CURRENT folder `<option>` as `selected`, so an untouched Save
10741    /// re-submits the current folder instead of silently un-foldering the feed.
10742    /// A loose (un-foldered) feed must mark "No folder" selected instead. Renders
10743    /// `ManageTemplate` directly so no PDS/sidecar round-trip is needed.
10744    #[test]
10745    fn manage_rename_row_preselects_current_folder() {
10746        let nav = Nav {
10747            handle: "@reader.example".to_string(),
10748            avatar: "RE".to_string(),
10749            view: "unread".to_string(),
10750            scope_qs: String::new(),
10751            folders: Vec::new(),
10752            loose_feeds: Vec::new(),
10753            manage_active: true,
10754        };
10755        let folder_options = vec![
10756            FolderOption {
10757                uri: "at://did:plc:x/app.folder/work".to_string(),
10758                name: "Work".to_string(),
10759            },
10760            FolderOption {
10761                uri: "at://did:plc:x/app.folder/fun".to_string(),
10762                name: "Fun".to_string(),
10763            },
10764        ];
10765        // A foldered feed (in "Work") and a loose feed (no folder), each with a
10766        // non-empty rkey so the rename form renders.
10767        let foldered = FeedView {
10768            rkey: "sub-foldered".to_string(),
10769            url: "https://work.example/feed.xml".to_string(),
10770            title: "Work Feed".to_string(),
10771            unread: 0,
10772            selected: false,
10773            folder: Some("at://did:plc:x/app.folder/work".to_string()),
10774        };
10775        let loose = FeedView {
10776            rkey: "sub-loose".to_string(),
10777            url: "https://loose.example/feed.xml".to_string(),
10778            title: "Loose Feed".to_string(),
10779            unread: 0,
10780            selected: false,
10781            folder: None,
10782        };
10783        let tmpl = ManageTemplate {
10784            version: VERSION,
10785            repo_url: REPO_URL,
10786            kofi_url: KOFI_URL,
10787            flash: String::new(),
10788            alert: String::new(),
10789            nav,
10790            folder_options,
10791            folders: vec![FolderView {
10792                rkey: "folder-work".to_string(),
10793                uri: "at://did:plc:x/app.folder/work".to_string(),
10794                name: "Work".to_string(),
10795                feeds: vec![foldered],
10796                selected: false,
10797            }],
10798            loose_feeds: vec![loose],
10799            standard_site: false,
10800        };
10801        let html = tmpl.render().unwrap();
10802
10803        // The foldered feed's "Work" option is pre-selected.
10804        assert!(
10805            html.contains(
10806                r#"<option value="at://did:plc:x/app.folder/work" selected>Work</option>"#
10807            ),
10808            "foldered feed must pre-select its current folder: {html}"
10809        );
10810        // The loose feed's "No folder" option is pre-selected (appears for the
10811        // loose row, which has folder=None).
10812        assert!(
10813            html.contains(r#"<option value="" selected>No folder</option>"#),
10814            "loose feed must pre-select 'No folder': {html}"
10815        );
10816    }
10817
10818    /// **The public stats page carries no user data.**
10819    ///
10820    /// It is reachable by anyone, so the thing worth pinning is what it does
10821    /// NOT say: nothing about how many people use the instance, nothing about
10822    /// which feeds fail, nothing about who reads what.
10823    #[tokio::test]
10824    async fn the_public_stats_page_exposes_no_user_data() {
10825        let state = test_state(&[]).await;
10826        store::ensure_seed(&state.db, &["did:plc:someone".to_string()])
10827            .await
10828            .unwrap();
10829
10830        let resp = router(state)
10831            .oneshot(
10832                Request::builder()
10833                    .uri("/stats")
10834                    .body(Body::empty())
10835                    .unwrap(),
10836            )
10837            .await
10838            .unwrap();
10839        assert_eq!(resp.status(), StatusCode::OK, "stats must be public");
10840
10841        let body = String::from_utf8(
10842            axum::body::to_bytes(resp.into_body(), usize::MAX)
10843                .await
10844                .unwrap()
10845                .to_vec(),
10846        )
10847        .unwrap();
10848
10849        // Structural checks, not word checks. The page's own prose says it
10850        // publishes no error rates, so searching for that PHRASE finds the
10851        // disclaimer rather than a leak — the first version of this test failed
10852        // on exactly that. What matters is whether identifiers or the
10853        // admin-only figures are present.
10854        assert!(
10855            !body.contains("did:"),
10856            "the public stats page leaked an identifier"
10857        );
10858        for admin_only in ["errp50ms", "p95ms", "live backend", "ok_count"] {
10859            assert!(
10860                !body.contains(admin_only),
10861                "the public page is showing the admin metrics column {admin_only:?}"
10862            );
10863        }
10864        // And it does render the aggregate it exists for.
10865        assert!(body.contains("Feeds tracked"));
10866        assert!(body.contains("Waiting to be polled"));
10867    }
10868
10869    /// **The two states that stop feeds updating must be visible.**
10870    ///
10871    /// `overdue` and `polled_last_hour` move in BOTH and distinguish neither —
10872    /// and `overdue` moves the WRONG WAY for backoff, because backoff is applied
10873    /// by pushing `next_poll` forward, so a feed failing every fetch drops out of
10874    /// the backlog and makes the page read healthier. That inversion is what this
10875    /// test pins: a broken feed must raise a number, not lower one.
10876    #[tokio::test]
10877    async fn stats_distinguishes_backoff_from_a_watermark_pause() {
10878        let state = test_state(&[]).await;
10879        // Three feeds: one healthy, one flaky, one long dead.
10880        for (url, errors) in [
10881            ("https://ok.example/f.xml", 0),
10882            ("https://flaky.example/f.xml", 2),
10883            ("https://dead.example/f.xml", 9),
10884        ] {
10885            store::upsert_feed(
10886                &state.db,
10887                &store::NewFeed {
10888                    url: url.to_string(),
10889                    // Pushed forward, exactly as backoff does — so none of these
10890                    // are counted as `overdue`.
10891                    next_poll: Some("2099-01-01T00:00:00Z".to_string()),
10892                    ..Default::default()
10893                },
10894            )
10895            .await
10896            .unwrap();
10897            for _ in 0..errors {
10898                store::bump_feed_errors(
10899                    &state.db,
10900                    url,
10901                    feed::FailureKind::Fetch,
10902                    "connection refused",
10903                )
10904                .await
10905                .unwrap();
10906            }
10907        }
10908
10909        let render_stats = |state: AppState| async move {
10910            let resp = router(state)
10911                .oneshot(
10912                    Request::builder()
10913                        .uri("/stats")
10914                        .body(Body::empty())
10915                        .unwrap(),
10916                )
10917                .await
10918                .unwrap();
10919            assert_eq!(resp.status(), StatusCode::OK);
10920            String::from_utf8(
10921                axum::body::to_bytes(resp.into_body(), usize::MAX)
10922                    .await
10923                    .unwrap()
10924                    .to_vec(),
10925            )
10926            .unwrap()
10927        };
10928
10929        // **The fixture must actually be RUNNING, or this test measures nothing.**
10930        // `test_state` leaves `schedulers_enabled` false, and `fetching_state`
10931        // checks that BEFORE the watermark — so without these two lines every
10932        // render below reports "off" and the watermark can never surface. The
10933        // assertions still passed, for reasons unrelated to what they name: see
10934        // the two comments below.
10935        state.runtime_health.set_schedulers_enabled(true);
10936        state
10937            .runtime_health
10938            .poll_tick_completed(crate::store::now_unix());
10939
10940        let body = render_stats(state.clone()).await;
10941        assert!(
10942            body.contains("Failing"),
10943            "backoff is still invisible on the public page"
10944        );
10945        // 2 failing, 1 of them badly (>= BADLY_BROKEN_ERRORS). Matched on the
10946        // value rather than on surrounding whitespace, so re-indenting the
10947        // template cannot break this.
10948        assert!(
10949            body.contains("2, 1 badly"),
10950            "expected '2, 1 badly' in the failing row; got:\n{}",
10951            body.split("Failing")
10952                .nth(1)
10953                .unwrap_or("")
10954                .chars()
10955                .take(300)
10956                .collect::<String>()
10957        );
10958        // Not paused, and the backlog is genuinely empty — which is exactly the
10959        // reading that used to be indistinguishable from healthy.
10960        //
10961        // **Asserted by EXCLUDING the other states, not by matching "running".**
10962        // The `off` row reads "the poller is not running on this instance", which
10963        // contains "running" — so the bare substring passed while the page was
10964        // reporting the exact opposite of what this line claims to check.
10965        assert!(
10966            !body.contains("the poller is not running")
10967                && !body.contains("the cache is at its size limit")
10968                && !body.contains("has not completed a round"),
10969            "expected the running state; the page reported a stopped one",
10970        );
10971
10972        // Now trip the watermark. Nothing in the database changes; only the
10973        // recorded runtime state does — which is the whole reason it needed a
10974        // home outside the log stream.
10975        state.runtime_health.set_watermark(true);
10976        let paused = render_stats(state.clone()).await;
10977        // Matched on the paused row's OWN sentence. The bare word "paused" also
10978        // appeared in the page's explanatory prose, so this assertion passed
10979        // whether or not the row rendered — and trimming that prose is what
10980        // exposed it. This phrase exists only inside the `paused` branch.
10981        assert!(
10982            paused.contains("the cache is at its size limit"),
10983            "a watermark pause is still invisible on the public page"
10984        );
10985
10986        // Still no identifiers: these are counts, not feeds.
10987        for leak in ["ok.example", "flaky.example", "dead.example", "did:"] {
10988            assert!(
10989                !paused.contains(leak),
10990                "the public page leaked {leak:?} while reporting failures"
10991            );
10992        }
10993    }
10994
10995    /// **`/admin/metrics` is gated, and nothing checked that it was.**
10996    ///
10997    /// Deleting the `admin_seed_dids` check left the entire suite green. That
10998    /// was survivable while the page held only aggregate timings; it is not now,
10999    /// because this branch puts **per-feed URLs and remote error text** behind
11000    /// that gate. A guarantee nothing checks is a comment, and this one is now
11001    /// the only thing standing between a signed-in stranger and the operational
11002    /// picture the handler's own doc says is not public.
11003    ///
11004    /// All three doors: no session, a session that is not an admin, and the
11005    /// admin itself.
11006    #[tokio::test]
11007    async fn admin_metrics_is_refused_to_everyone_but_an_admin() {
11008        let admin = "did:plc:adminseed";
11009        // **Only the admin is in ALLOWED_DIDS**, because `admin_seed_dids()`
11010        // IS that list — deliberately, per its doc: "the same people I trust on
11011        // this instance". Production sets it to the bootstrap DID alone.
11012        //
11013        // A genuine non-admin is therefore someone holding a beta seat granted
11014        // by an invite, not by the allow-list. Seeding both would have made
11015        // both admins and quietly turned the 403 assertion below into a test of
11016        // nothing — which is exactly what the first draft of this did.
11017        let state = test_state(&[admin]).await;
11018        store::grant_access(&state.db, "did:plc:ordinaryuser", None, "invite", None)
11019            .await
11020            .unwrap();
11021        let url = "https://broken.example/f.xml";
11022        store::upsert_feed(
11023            &state.db,
11024            &store::NewFeed {
11025                url: url.to_string(),
11026                ..Default::default()
11027            },
11028        )
11029        .await
11030        .unwrap();
11031        store::bump_feed_errors(
11032            &state.db,
11033            url,
11034            feed::FailureKind::Fetch,
11035            "SENTINEL_ADMIN_ONLY",
11036        )
11037        .await
11038        .unwrap();
11039
11040        let get = |state: AppState, cookie: Option<String>| async move {
11041            let mut req = Request::builder().uri("/admin/metrics");
11042            if let Some(c) = cookie {
11043                req = req.header(header::COOKIE, c);
11044            }
11045            let resp = router(state)
11046                .oneshot(req.body(Body::empty()).unwrap())
11047                .await
11048                .unwrap();
11049            let status = resp.status();
11050            let body = String::from_utf8(
11051                axum::body::to_bytes(resp.into_body(), usize::MAX)
11052                    .await
11053                    .unwrap()
11054                    .to_vec(),
11055            )
11056            .unwrap();
11057            (status, body)
11058        };
11059
11060        // No session at all.
11061        let (status, body) = get(state.clone(), None).await;
11062        assert_eq!(status, StatusCode::UNAUTHORIZED);
11063        assert!(
11064            !body.contains("SENTINEL_ADMIN_ONLY"),
11065            "leaked to anonymous: {body}"
11066        );
11067
11068        // A real, signed-in user who is not an admin.
11069        let ordinary = session_cookie(&state, "did:plc:ordinaryuser", None);
11070        let (status, body) = get(state.clone(), Some(ordinary)).await;
11071        assert_eq!(
11072            status,
11073            StatusCode::FORBIDDEN,
11074            "a non-admin session was let in"
11075        );
11076        assert!(
11077            !body.contains("SENTINEL_ADMIN_ONLY") && !body.contains("broken.example"),
11078            "leaked to a non-admin: {body}",
11079        );
11080
11081        // The admin does get it — otherwise the two refusals above are
11082        // satisfied by the endpoint being broken for everyone.
11083        let admin_cookie = session_cookie(&state, admin, None);
11084        let (status, body) = get(state, Some(admin_cookie)).await;
11085        assert_eq!(status, StatusCode::OK);
11086        assert!(
11087            body.contains("SENTINEL_ADMIN_ONLY"),
11088            "admin cannot see it: {body}"
11089        );
11090    }
11091
11092    /// **The cause a public count cannot carry belongs on the admin page.**
11093    ///
11094    /// The public histogram is four coarse buckets, and `fetch` is the coarsest:
11095    /// #159's own error — `guarded_get` bailing on a 304 — lands there beside
11096    /// DNS failure, timeout and SSRF refusal. So the histogram alone would NOT
11097    /// have separated "sixty dead publishers" from "one bug here", which is the
11098    /// case it was justified by.
11099    ///
11100    /// The answer is not a finer public vocabulary — `/stats` promises never
11101    /// which feed and never whose, and a bucket per error string would break
11102    /// that. It is to put the detail where per-feed data is already allowed.
11103    /// `/admin/metrics` is gated on `ALLOWED_DIDS` and already carries an
11104    /// operational picture.
11105    ///
11106    /// Asserts both halves: the detail IS on the admin page, and is NOT on the
11107    /// public one.
11108    #[tokio::test]
11109    async fn the_admin_page_names_failing_feeds_and_the_public_page_does_not() {
11110        let admin = "did:plc:adminseed";
11111        let state = test_state(&[admin]).await;
11112        let url = "https://broken.example/f.xml";
11113        store::upsert_feed(
11114            &state.db,
11115            &store::NewFeed {
11116                url: url.to_string(),
11117                ..Default::default()
11118            },
11119        )
11120        .await
11121        .unwrap();
11122        store::bump_feed_errors(
11123            &state.db,
11124            url,
11125            feed::FailureKind::Fetch,
11126            "SENTINEL_REDIRECT_NO_LOCATION",
11127        )
11128        .await
11129        .unwrap();
11130
11131        let cookie = session_cookie(&state, admin, None);
11132        let resp = router(state.clone())
11133            .oneshot(
11134                Request::builder()
11135                    .uri("/admin/metrics")
11136                    .header(header::COOKIE, cookie)
11137                    .body(Body::empty())
11138                    .unwrap(),
11139            )
11140            .await
11141            .unwrap();
11142        assert_eq!(resp.status(), StatusCode::OK);
11143        let admin_body = String::from_utf8(
11144            axum::body::to_bytes(resp.into_body(), usize::MAX)
11145                .await
11146                .unwrap()
11147                .to_vec(),
11148        )
11149        .unwrap();
11150        assert!(
11151            admin_body.contains("SENTINEL_REDIRECT_NO_LOCATION"),
11152            "the admin page does not carry the failure detail: {admin_body}",
11153        );
11154        assert!(
11155            admin_body.contains("broken.example"),
11156            "the admin page does not name the failing feed: {admin_body}",
11157        );
11158
11159        // The public page still carries neither.
11160        let resp = router(state)
11161            .oneshot(
11162                Request::builder()
11163                    .uri("/stats")
11164                    .body(Body::empty())
11165                    .unwrap(),
11166            )
11167            .await
11168            .unwrap();
11169        let public = String::from_utf8(
11170            axum::body::to_bytes(resp.into_body(), usize::MAX)
11171                .await
11172                .unwrap()
11173                .to_vec(),
11174        )
11175        .unwrap();
11176        for secret in ["SENTINEL_REDIRECT_NO_LOCATION", "broken.example"] {
11177            assert!(
11178                !public.contains(secret),
11179                "{secret:?} reached the PUBLIC stats page: {public}",
11180            );
11181        }
11182    }
11183
11184    /// **A direct poll must settle the error columns, like the scheduler does.**
11185    ///
11186    /// `add_subscription` polls through `feed::poll_feed` rather than the
11187    /// scheduler, and `poll_feed` writes validators and `last_polled` but never
11188    /// touches `consecutive_errors` — that is the scheduler's job, and this path
11189    /// is not the scheduler.
11190    ///
11191    /// So a feed that was failing, is re-subscribed, and polls SUCCESSFULLY kept
11192    /// its old count and its old cause: the public page went on reporting it
11193    /// under `Failing`, under `badly_broken`, and under a cause, for as long as
11194    /// the stale backoff horizon lasted — up to 24h — while the reader was
11195    /// demonstrably fetching it.
11196    #[tokio::test]
11197    async fn a_successful_direct_poll_clears_a_stale_failure() {
11198        let state = test_state(&[]).await;
11199        let url = "https://recovered.example/f.xml";
11200        store::upsert_feed(
11201            &state.db,
11202            &store::NewFeed {
11203                url: url.to_string(),
11204                ..Default::default()
11205            },
11206        )
11207        .await
11208        .unwrap();
11209        store::bump_feed_errors(&state.db, url, feed::FailureKind::Fetch, "SENTINEL_OLD")
11210            .await
11211            .unwrap();
11212        // Park it on a stale backoff horizon, as a real failing feed would be.
11213        sqlx::query("UPDATE feeds SET next_poll = '2099-01-01T00:00:00Z' WHERE url = ?1")
11214            .bind(url)
11215            .execute(&state.db)
11216            .await
11217            .unwrap();
11218
11219        // The publisher is fixed: a successful poll happens on this path.
11220        feed::settle_poll(
11221            &state.db,
11222            url,
11223            &feed::PollOutcome::NotModified,
11224            state.config.poll_interval,
11225        )
11226        .await;
11227
11228        let row: (i64, Option<String>, Option<String>) = sqlx::query_as(
11229            "SELECT consecutive_errors, last_error_kind, next_poll FROM feeds WHERE url = ?1",
11230        )
11231        .bind(url)
11232        .fetch_one(&state.db)
11233        .await
11234        .unwrap();
11235        assert_eq!(row.0, 0, "a successful direct poll left the error streak");
11236        assert_eq!(row.1, None, "a successful direct poll left a stale cause");
11237        // **The half the first fix missed.** Clearing the count fixed the
11238        // REPORTING; the feed stayed parked until 2099. A working feed must be
11239        // rescheduled on its normal cadence, not left on the failure horizon.
11240        let next = row.2.expect("next_poll was cleared to NULL");
11241        // Not merely "moved off 2099" — rescheduled on the CADENCE, not a
11242        // backoff. A mutation that reschedules successes with backoff_for(1)
11243        // (5 min) also moves it off 2099, so the interval is asserted.
11244        let parsed = chrono::DateTime::parse_from_rfc3339(&next).unwrap();
11245        let delta = parsed
11246            .signed_duration_since(chrono::Utc::now())
11247            .num_seconds();
11248        let cadence = state.config.poll_interval.as_secs() as i64;
11249        assert!(
11250            (cadence - 60..=cadence + 60).contains(&delta),
11251            "expected rescheduling on the {cadence}s cadence, got {delta}s (next_poll={next})"
11252        );
11253    }
11254
11255    /// The mirror case: a first poll that FAILS must be visible at all.
11256    ///
11257    /// `Ok(outcome) => info!(...)` discarded a `PollOutcome::Failed`, so a
11258    /// subscription whose very first fetch failed sat at `consecutive_errors = 0`
11259    /// with a NULL cause — invisible to the page built to count exactly that.
11260    #[tokio::test]
11261    async fn a_failing_direct_poll_is_recorded() {
11262        let state = test_state(&[]).await;
11263        let url = "https://born-broken.example/f.xml";
11264        store::upsert_feed(
11265            &state.db,
11266            &store::NewFeed {
11267                url: url.to_string(),
11268                ..Default::default()
11269            },
11270        )
11271        .await
11272        .unwrap();
11273
11274        feed::settle_poll(
11275            &state.db,
11276            url,
11277            &feed::PollOutcome::Failed {
11278                backoff: std::time::Duration::from_secs(300),
11279                kind: feed::FailureKind::Parse,
11280                detail: "SENTINEL_BORN_BROKEN".to_string(),
11281            },
11282            state.config.poll_interval,
11283        )
11284        .await;
11285
11286        let row: (i64, Option<String>, Option<String>) = sqlx::query_as(
11287            "SELECT consecutive_errors, last_error_kind, next_poll FROM feeds WHERE url = ?1",
11288        )
11289        .bind(url)
11290        .fetch_one(&state.db)
11291        .await
11292        .unwrap();
11293        assert_eq!(row.0, 1, "a failed first poll was not counted");
11294        assert_eq!(
11295            row.1.as_deref(),
11296            Some("parse"),
11297            "its cause was not recorded"
11298        );
11299        // And it is BACKED OFF on the schedule the scheduler would use — not
11300        // left with a NULL next_poll that `due_feeds` sorts first and re-polls
11301        // on the very next tick.
11302        let next = row.2.expect("a failed direct poll left next_poll NULL");
11303        let parsed = chrono::DateTime::parse_from_rfc3339(&next).unwrap();
11304        let delta = parsed
11305            .signed_duration_since(chrono::Utc::now())
11306            .num_seconds();
11307        assert!(
11308            (240..=360).contains(&delta),
11309            "expected ~300s backoff after one failure, got {delta}s (next_poll={next})"
11310        );
11311    }
11312
11313    /// **The breakdown must sum to the Failing figure above it.**
11314    ///
11315    /// The histogram counts `last_error_kind IS NOT NULL`; `Failing` counts
11316    /// `consecutive_errors > 0`. On a migrated database every row that was
11317    /// already failing has a NULL kind — correctly, it was never recorded — so
11318    /// the two do not reconcile and the page shows "70 failing" beside "3
11319    /// fetch" with 67 silently unaccounted for. On deploy day the row vanishes
11320    /// entirely while the prose still promises a breakdown.
11321    ///
11322    /// An explicit `unknown` bucket is the honest shape: the page says how many
11323    /// it cannot explain rather than omitting them.
11324    #[tokio::test]
11325    async fn the_failure_breakdown_accounts_for_every_failing_feed() {
11326        let state = test_state(&[]).await;
11327        // Two legacy rows: failing, with no recorded cause.
11328        for url in [
11329            "https://legacy1.example/f.xml",
11330            "https://legacy2.example/f.xml",
11331        ] {
11332            store::upsert_feed(
11333                &state.db,
11334                &store::NewFeed {
11335                    url: url.to_string(),
11336                    next_poll: Some("2099-01-01T00:00:00Z".to_string()),
11337                    ..Default::default()
11338                },
11339            )
11340            .await
11341            .unwrap();
11342            sqlx::query("UPDATE feeds SET consecutive_errors = 4 WHERE url = ?1")
11343                .bind(url)
11344                .execute(&state.db)
11345                .await
11346                .unwrap();
11347        }
11348        // One row with a recorded cause.
11349        store::upsert_feed(
11350            &state.db,
11351            &store::NewFeed {
11352                url: "https://known.example/f.xml".to_string(),
11353                next_poll: Some("2099-01-01T00:00:00Z".to_string()),
11354                ..Default::default()
11355            },
11356        )
11357        .await
11358        .unwrap();
11359        store::bump_feed_errors(
11360            &state.db,
11361            "https://known.example/f.xml",
11362            feed::FailureKind::Status,
11363            "SENTINEL",
11364        )
11365        .await
11366        .unwrap();
11367
11368        let now = chrono::Utc::now();
11369        let health = store::poll_health(
11370            &state.db,
11371            &now.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
11372            &(now - chrono::Duration::hours(1)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
11373        )
11374        .await
11375        .unwrap();
11376        let counted: i64 = health.failure_kinds.iter().map(|(_, n)| n).sum();
11377        assert_eq!(
11378            counted, health.in_backoff,
11379            "the breakdown ({counted}) does not account for all {} failing feeds: {:?}",
11380            health.in_backoff, health.failure_kinds,
11381        );
11382        assert!(
11383            health
11384                .failure_kinds
11385                .iter()
11386                .any(|(k, n)| k == "unknown" && *n == 2),
11387            "no unknown bucket for the legacy rows: {:?}",
11388            health.failure_kinds,
11389        );
11390    }
11391
11392    /// **The breakdown is ordered by count, and the assertion can see it.**
11393    ///
11394    /// The first version of this asserted with three `contains` calls, which
11395    /// cannot observe order — deleting `ORDER BY` from the query passed.
11396    #[tokio::test]
11397    async fn the_failure_breakdown_is_ordered_by_count() {
11398        let state = test_state(&[]).await;
11399        for (url, kind, n) in [
11400            ("https://p1.example/f.xml", feed::FailureKind::Parse, 1),
11401            ("https://f1.example/f.xml", feed::FailureKind::Fetch, 1),
11402            ("https://f2.example/f.xml", feed::FailureKind::Fetch, 1),
11403            ("https://f3.example/f.xml", feed::FailureKind::Fetch, 1),
11404            ("https://s1.example/f.xml", feed::FailureKind::Status, 1),
11405            ("https://s2.example/f.xml", feed::FailureKind::Status, 1),
11406        ] {
11407            store::upsert_feed(
11408                &state.db,
11409                &store::NewFeed {
11410                    url: url.to_string(),
11411                    next_poll: Some("2099-01-01T00:00:00Z".to_string()),
11412                    ..Default::default()
11413                },
11414            )
11415            .await
11416            .unwrap();
11417            for _ in 0..n {
11418                store::bump_feed_errors(&state.db, url, kind, "d")
11419                    .await
11420                    .unwrap();
11421            }
11422        }
11423        let now = chrono::Utc::now();
11424        let health = store::poll_health(
11425            &state.db,
11426            &now.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
11427            &(now - chrono::Duration::hours(1)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
11428        )
11429        .await
11430        .unwrap();
11431        let labels: Vec<&str> = health
11432            .failure_kinds
11433            .iter()
11434            .map(|(k, _)| k.as_str())
11435            .collect();
11436        assert_eq!(
11437            labels,
11438            ["fetch", "status", "parse"],
11439            "not ordered by count, descending: {:?}",
11440            health.failure_kinds,
11441        );
11442    }
11443
11444    /// **Failing feeds are grouped by CAUSE, and still never named.**
11445    ///
11446    /// `badly_broken` could say that sixty feeds were failing and not whether
11447    /// that was sixty dead publishers or one bug here. It was the latter — #159,
11448    /// a `304 Not Modified` read as a malformed redirect — and the page could
11449    /// not say so, which is most of why it went unexamined.
11450    ///
11451    /// The second half of this test is the constraint that shapes the first:
11452    /// `/stats` is public and promises machines-not-people, *never which feed
11453    /// and never whose*. A histogram of causes keeps that promise; a list of
11454    /// failing URLs would break it, and is the obvious way to build this.
11455    #[tokio::test]
11456    async fn stats_groups_failures_by_cause_without_naming_any_feed() {
11457        let state = test_state(&[]).await;
11458        for (url, kind, detail, errors) in [
11459            // Detail strings are distinctive SENTINELS, not plausible English.
11460            // A first pass used "not a feed", which the page's own explanation
11461            // of the `parse` kind contains verbatim — the privacy assertion
11462            // fired on static copy rather than on a leak. A sentinel cannot
11463            // collide with prose.
11464            (
11465                "https://a.example/f.xml",
11466                feed::FailureKind::Fetch,
11467                "SENTINEL_CONNREFUSED",
11468                3,
11469            ),
11470            (
11471                "https://b.example/f.xml",
11472                feed::FailureKind::Fetch,
11473                "SENTINEL_DNSFAIL",
11474                2,
11475            ),
11476            (
11477                "https://c.example/f.xml",
11478                feed::FailureKind::Status,
11479                "SENTINEL_404",
11480                1,
11481            ),
11482            (
11483                "https://d.example/f.xml",
11484                feed::FailureKind::Parse,
11485                "SENTINEL_UNPARSEABLE",
11486                1,
11487            ),
11488        ] {
11489            store::upsert_feed(
11490                &state.db,
11491                &store::NewFeed {
11492                    url: url.to_string(),
11493                    next_poll: Some("2099-01-01T00:00:00Z".to_string()),
11494                    ..Default::default()
11495                },
11496            )
11497            .await
11498            .unwrap();
11499            for _ in 0..errors {
11500                store::bump_feed_errors(&state.db, url, kind, detail)
11501                    .await
11502                    .unwrap();
11503            }
11504        }
11505
11506        let resp = router(state.clone())
11507            .oneshot(
11508                Request::builder()
11509                    .uri("/stats")
11510                    .body(Body::empty())
11511                    .unwrap(),
11512            )
11513            .await
11514            .unwrap();
11515        assert_eq!(resp.status(), StatusCode::OK);
11516        let body = String::from_utf8(
11517            axum::body::to_bytes(resp.into_body(), usize::MAX)
11518                .await
11519                .unwrap()
11520                .to_vec(),
11521        )
11522        .unwrap();
11523
11524        // Descending by count: two fetch, then one each, tie-broken by name.
11525        assert!(
11526            body.contains("2 fetch") && body.contains("1 status") && body.contains("1 parse"),
11527            "the cause histogram did not render: {body}",
11528        );
11529
11530        // **The privacy half.** No feed URL, host, or error detail reaches the
11531        // public page — only counts by kind.
11532        for secret in [
11533            "a.example",
11534            "b.example",
11535            "c.example",
11536            "d.example",
11537            "SENTINEL_CONNREFUSED",
11538            "SENTINEL_DNSFAIL",
11539            "SENTINEL_404",
11540            "SENTINEL_UNPARSEABLE",
11541        ] {
11542            assert!(
11543                !body.contains(secret),
11544                "{secret:?} reached the PUBLIC stats page: {body}",
11545            );
11546        }
11547    }
11548
11549    /// `/health` must prove the process can reach its database, and must report
11550    /// the loop state without letting it change the status code.
11551    #[tokio::test]
11552    async fn health_checks_the_database_and_reports_the_loops() {
11553        let state = test_state(&[]).await;
11554        let body_of = |state: AppState| async move {
11555            let resp = router(state)
11556                .oneshot(
11557                    Request::builder()
11558                        .uri("/health")
11559                        .body(Body::empty())
11560                        .unwrap(),
11561                )
11562                .await
11563                .unwrap();
11564            let status = resp.status();
11565            let body = String::from_utf8(
11566                axum::body::to_bytes(resp.into_body(), usize::MAX)
11567                    .await
11568                    .unwrap()
11569                    .to_vec(),
11570            )
11571            .unwrap();
11572            (status, body)
11573        };
11574
11575        // The boot stamp is what `main` sets; the router alone does not, so this
11576        // starts "unknown" and the uptime branch below drives it explicitly.
11577        state
11578            .runtime_health
11579            .set_started_at(chrono::Utc::now().timestamp());
11580
11581        let (status, body) = body_of(state.clone()).await;
11582        assert_eq!(status, StatusCode::OK);
11583        assert!(
11584            body.contains("db: ok"),
11585            "health did not probe the DB: {body}"
11586        );
11587        assert!(
11588            body.contains("uptime:"),
11589            "no uptime — the first thing anyone asks about a container that may \
11590             be restarting: {body}"
11591        );
11592        assert!(body.contains("poller:"), "no scheduler heartbeat: {body}");
11593        assert!(body.contains("polling-paused: no"), "{body}");
11594        assert!(body.contains("backend:"), "{body}");
11595        assert!(body.contains("oauth-runtime:"), "{body}");
11596
11597        // A watermark pause is REPORTED but must not fail the check. A failed
11598        // check DEREGISTERS this machine from the proxy — and it is the only
11599        // machine — so it would turn "feeds are behind" into "the site is down"
11600        // for as long as the disk stays full.
11601        state.runtime_health.set_watermark(true);
11602        state.runtime_health.set_schedulers_enabled(true);
11603        let (status, body) = body_of(state.clone()).await;
11604        assert_eq!(
11605            status,
11606            StatusCode::OK,
11607            "a watermark pause must not fail the liveness check: {body}"
11608        );
11609        assert!(body.contains("polling-paused: yes"), "{body}");
11610        // Schedulers on but no tick yet — and that must not read as "0s ago",
11611        // which is the healthiest possible answer to an unanswered question.
11612        assert!(
11613            body.contains("poller: not-yet-ticked"),
11614            "a never-ticked poller must say so: {body}"
11615        );
11616
11617        // A stale heartbeat is likewise reported, not fatal.
11618        let stale_after = health_tick_stale_secs(configured_poll_tick());
11619        let long_ago = chrono::Utc::now().timestamp() - (stale_after + 60);
11620        state.runtime_health.poll_tick_completed(long_ago);
11621        let (status, body) = body_of(state.clone()).await;
11622        assert_eq!(
11623            status,
11624            StatusCode::OK,
11625            "a stale poller must not 503: {body}"
11626        );
11627        assert!(body.contains("poller: stale"), "{body}");
11628
11629        // **A poller that has never ticked stops being benign.**
11630        //
11631        // In a crash loop with 30 s+ boot cycles the poller never reaches its
11632        // first tick, so `not-yet-ticked` was reported forever and the heartbeat
11633        // could not detect the one failure mode the startup delays were added
11634        // for. It is read against uptime now.
11635        state.runtime_health.poll_tick_completed(0); // reset to "never"
11636        state
11637            .runtime_health
11638            .set_started_at(chrono::Utc::now().timestamp() - (HEALTH_FIRST_TICK_GRACE_SECS + 60));
11639        let (status, body) = body_of(state.clone()).await;
11640        assert_eq!(status, StatusCode::OK);
11641        assert!(
11642            body.contains("poller: stale never-ticked"),
11643            "a poller that never ticked long after boot still reads as benign: {body}"
11644        );
11645
11646        // A closed pool is a real outage: nothing can be served, and a restart is
11647        // the correct response. THIS is what the status code is for.
11648        state.db.close().await;
11649        let (status, body) = body_of(state.clone()).await;
11650        assert_eq!(
11651            status,
11652            StatusCode::SERVICE_UNAVAILABLE,
11653            "an unreachable database must fail the check: {body}"
11654        );
11655        assert!(body.starts_with("FAIL"), "{body}");
11656        // Coarse, not the raw sqlx error: an unauthenticated caller learning
11657        // exactly which failure it hit is an attack-progress oracle, and this
11658        // endpoint is exempt from the origin lock.
11659        assert!(
11660            !body.contains("PoolClosed") && !body.contains("sqlx"),
11661            "health leaked the raw database error to an unauthenticated caller: {body}"
11662        );
11663    }
11664
11665    /// The staleness threshold must track the configured tick.
11666    ///
11667    /// Hardcoded at 15 minutes, an operator who raised
11668    /// `FEATHERREADER_POLL_TICK_SECS` above 900 got a permanent `poller: stale`
11669    /// in the body the deployment docs tell them to alert on.
11670    #[test]
11671    fn the_stale_threshold_follows_the_poll_tick() {
11672        // A fast tick keeps the floor — five 60 s ticks is 5 minutes, and
11673        // alerting that early would fire on any brief hiccup.
11674        assert_eq!(
11675            health_tick_stale_secs(Duration::from_secs(60)),
11676            HEALTH_TICK_STALE_FLOOR_SECS
11677        );
11678        // A slow tick raises it, so a legitimately-configured loop is never
11679        // permanently "stale".
11680        let slow = Duration::from_secs(30 * 60);
11681        assert!(
11682            health_tick_stale_secs(slow) > slow.as_secs() as i64,
11683            "a 30-minute tick must not be stale after one interval"
11684        );
11685        assert_eq!(health_tick_stale_secs(slow), 30 * 60 * 5);
11686        // And it cannot overflow into nonsense on an absurd value.
11687        assert!(health_tick_stale_secs(Duration::from_secs(u64::MAX)) > 0);
11688    }
11689
11690    /// `/stats` must distinguish "nothing is polling" from "polling is fine".
11691    ///
11692    /// `polling_paused` alone rendered "running" for three different states,
11693    /// including the two where nothing polls at all — on the page added to
11694    /// answer exactly that question.
11695    #[tokio::test]
11696    async fn stats_does_not_call_a_stopped_poller_running() {
11697        let state = test_state(&[]).await;
11698        let render = |state: AppState| async move {
11699            let resp = router(state)
11700                .oneshot(
11701                    Request::builder()
11702                        .uri("/stats")
11703                        .body(Body::empty())
11704                        .unwrap(),
11705                )
11706                .await
11707                .unwrap();
11708            assert_eq!(resp.status(), StatusCode::OK);
11709            String::from_utf8(
11710                axum::body::to_bytes(resp.into_body(), usize::MAX)
11711                    .await
11712                    .unwrap()
11713                    .to_vec(),
11714            )
11715            .unwrap()
11716        };
11717
11718        // Schedulers never started: not "running".
11719        let body = render(state.clone()).await;
11720        assert!(
11721            body.contains("the poller is not running on this instance"),
11722            "a disabled poller renders as healthy"
11723        );
11724
11725        // Started, but no tick has finished yet.
11726        state.runtime_health.set_schedulers_enabled(true);
11727        let body = render(state.clone()).await;
11728        assert!(
11729            body.contains("no poll has finished since this instance booted"),
11730            "a poller that has not ticked renders as healthy"
11731        );
11732
11733        // Ticking: running.
11734        state
11735            .runtime_health
11736            .poll_tick_completed(chrono::Utc::now().timestamp());
11737        let body = render(state.clone()).await;
11738        assert!(
11739            body.contains("running"),
11740            "a healthy poller must read as running"
11741        );
11742
11743        // Paused at the watermark still wins over "running".
11744        state.runtime_health.set_watermark(true);
11745        let body = render(state.clone()).await;
11746        assert!(
11747            body.contains("the cache is at its size limit"),
11748            "a watermark pause is hidden once the poller is ticking"
11749        );
11750    }
11751
11752    /// **An UNMEASURED database must not fail the check.**
11753    ///
11754    /// `/health` is the one path exempt from the Cloudflare origin lock and
11755    /// absent from the rate limiter, and `DbProbeGuard` releases its claim on
11756    /// drop WITHOUT recording a verdict — so a cancelled request (a client
11757    /// disconnect is enough) leaves the verdict at "none", and a concurrent
11758    /// caller reads it. Treating that as a failure turned an unauthenticated
11759    /// request into a lever on the only signal the platform acts on. The
11760    /// previous version of this code had the opposite bug and reported `ok` for
11761    /// a database nothing had read; "unknown" is neither.
11762    #[tokio::test]
11763    async fn health_reports_an_unmeasured_database_without_failing() {
11764        use crate::runtime_health::DbProbe;
11765        let state = test_state(&[]).await;
11766
11767        // Hold the probe claim, exactly as an in-flight request would, and never
11768        // record a verdict — the cancelled-request state.
11769        let held = state
11770            .runtime_health
11771            .begin_db_probe()
11772            .unwrap_or_else(|_| panic!("a fresh RuntimeHealth must grant the first claim"));
11773
11774        let resp = router(state.clone())
11775            .oneshot(
11776                Request::builder()
11777                    .uri("/health")
11778                    .body(Body::empty())
11779                    .unwrap(),
11780            )
11781            .await
11782            .unwrap();
11783        let status = resp.status();
11784        let body = String::from_utf8(
11785            axum::body::to_bytes(resp.into_body(), usize::MAX)
11786                .await
11787                .unwrap()
11788                .to_vec(),
11789        )
11790        .unwrap();
11791        drop(held);
11792
11793        assert_eq!(
11794            status,
11795            StatusCode::OK,
11796            "an unmeasured database failed the check, which an unauthenticated \
11797             caller can cause on demand: {body}"
11798        );
11799        assert!(
11800            body.contains("db: unknown"),
11801            "the unmeasured state must still be REPORTED: {body}"
11802        );
11803        assert!(!body.starts_with("FAIL"), "{body}");
11804        // **And it must not read as `ok` either.** `fly.toml` tells operators to
11805        // alert on the BODY for everything the status code ignores, so a first
11806        // line identical to the healthy one makes a monitor keying on `^ok` read
11807        // green in exactly the state this enum exists to surface.
11808        assert!(
11809            !body.starts_with("ok"),
11810            "the unmeasured state is indistinguishable from healthy to a \
11811             body-matching monitor: {body}"
11812        );
11813        assert!(body.starts_with("unknown"), "{body}");
11814
11815        // **A BORROWED failure must 503 too.**
11816        //
11817        // This previously recorded `Failed` and then closed the pool — but
11818        // `record` consumes the guard and releases the claim, so the request won
11819        // it, ran a live probe against the closed pool, and failed on its own.
11820        // The 503 passed for the wrong reason and the borrow path — the whole
11821        // point of the three-state enum on the read side — had no coverage.
11822        //
11823        // Holding the claim forces the borrow, so the recorded verdict is what
11824        // gets reported.
11825        let held = state
11826            .runtime_health
11827            .begin_db_probe()
11828            .unwrap_or_else(|_| panic!("claim"));
11829        state
11830            .runtime_health
11831            .record_for_test(DbProbe::Failed("unavailable".to_string()));
11832        let resp = router(state.clone())
11833            .oneshot(
11834                Request::builder()
11835                    .uri("/health")
11836                    .body(Body::empty())
11837                    .unwrap(),
11838            )
11839            .await
11840            .unwrap();
11841        let status = resp.status();
11842        let body = String::from_utf8(
11843            axum::body::to_bytes(resp.into_body(), usize::MAX)
11844                .await
11845                .unwrap()
11846                .to_vec(),
11847        )
11848        .unwrap();
11849        drop(held);
11850        assert_eq!(
11851            status,
11852            StatusCode::SERVICE_UNAVAILABLE,
11853            "a BORROWED failure verdict must fail the check, not just a freshly \
11854             measured one: {body}"
11855        );
11856        assert!(body.starts_with("FAIL"), "{body}");
11857
11858        state.db.close().await;
11859        let resp = router(state.clone())
11860            .oneshot(
11861                Request::builder()
11862                    .uri("/health")
11863                    .body(Body::empty())
11864                    .unwrap(),
11865            )
11866            .await
11867            .unwrap();
11868        assert_eq!(
11869            resp.status(),
11870            StatusCode::SERVICE_UNAVAILABLE,
11871            "a measured database failure must still fail the check"
11872        );
11873    }
11874
11875    /// **A disconnected client must not be able to cancel the probe.**
11876    ///
11877    /// Axum drops the handler future when a caller goes away. With the probe
11878    /// inline that dropped it mid-flight and released the claim WITHOUT
11879    /// recording a verdict — which let an unauthenticated caller manufacture the
11880    /// no-verdict state on demand and freeze what every other caller, including
11881    /// Fly's own check, reads. The probe runs detached now, so the verdict is
11882    /// recorded whatever happens to the request that started it.
11883    #[tokio::test]
11884    async fn an_abandoned_request_still_records_its_probe() {
11885        use crate::runtime_health::DbProbe;
11886        let state = test_state(&[]).await;
11887        let rh = state.runtime_health.clone();
11888
11889        // Drive /health and abandon it immediately — the disconnect case.
11890        let app = router(state.clone());
11891        let fut = app.oneshot(
11892            Request::builder()
11893                .uri("/health")
11894                .body(Body::empty())
11895                .unwrap(),
11896        );
11897        let handle = tokio::spawn(fut);
11898        handle.abort();
11899        let _ = handle.await;
11900
11901        // The detached probe still completes and publishes a verdict, so the
11902        // claim is free and the next caller gets a MEASURED answer.
11903        for _ in 0..50 {
11904            if rh.begin_db_probe().is_ok() {
11905                break;
11906            }
11907            tokio::time::sleep(Duration::from_millis(20)).await;
11908        }
11909        let resp = router(state.clone())
11910            .oneshot(
11911                Request::builder()
11912                    .uri("/health")
11913                    .body(Body::empty())
11914                    .unwrap(),
11915            )
11916            .await
11917            .unwrap();
11918        let body = String::from_utf8(
11919            axum::body::to_bytes(resp.into_body(), usize::MAX)
11920                .await
11921                .unwrap()
11922                .to_vec(),
11923        )
11924        .unwrap();
11925        assert!(
11926            body.contains("db: ok"),
11927            "after an abandoned request the next caller still reads an \
11928             unmeasured database — the probe was cancelled with it: {body}"
11929        );
11930        // Sanity: the type still distinguishes the three states.
11931        assert_ne!(DbProbe::Unknown, DbProbe::Ok);
11932    }
11933
11934    /// **The probe must read a real page.**
11935    ///
11936    /// `SELECT 1` compiles to `Init/Integer/ResultRow/Halt` — no `OpenRead`, so
11937    /// it never touches a b-tree and returns success against a corrupted
11938    /// database. Asserted by asking SQLite what the statement actually compiles
11939    /// to, so it survives someone "simplifying" the query later.
11940    #[tokio::test]
11941    async fn the_health_probe_opens_a_real_table() {
11942        use sqlx::Row;
11943        let state = test_state(&[]).await;
11944        // `EXPLAIN` lists the VM program; the `opcode` column is the second.
11945        let opcodes = |sql: &'static str| {
11946            let db = state.db.clone();
11947            async move {
11948                sqlx::query(sql)
11949                    .fetch_all(&db)
11950                    .await
11951                    .unwrap()
11952                    .into_iter()
11953                    .map(|r| r.get::<String, _>("opcode"))
11954                    .collect::<Vec<String>>()
11955            }
11956        };
11957
11958        // The statement `health_db_probe` really runs — it is the sole path, so
11959        // there is no second string for the handler to use instead.
11960        let explain: &'static str =
11961            Box::leak(format!("EXPLAIN {HEALTH_DB_PROBE_SQL}").into_boxed_str());
11962        let probe = opcodes(explain).await;
11963        // And the probe itself works against a real schema.
11964        assert!(
11965            health_db_probe(&state.db).await.is_ok(),
11966            "the probe does not run against the real schema",
11967        );
11968        assert!(
11969            probe.iter().any(|op| op == "OpenRead"),
11970            "the health probe reads no page; it cannot detect a broken database: {probe:?}"
11971        );
11972        // And the bare form genuinely does not, which is the whole point.
11973        let bare = opcodes("EXPLAIN SELECT 1").await;
11974        assert!(
11975            !bare.iter().any(|op| op == "OpenRead"),
11976            "premise check failed: bare SELECT 1 now reads a page: {bare:?}"
11977        );
11978    }
11979
11980    /// A fresh instance says "never", not "0" — which would read as "polled
11981    /// just now", the opposite of the truth.
11982    #[test]
11983    fn an_instance_that_has_never_polled_says_so() {
11984        assert_eq!(humanise_ago(None), "never");
11985        assert_eq!(humanise_ago(Some(0)), "0s ago");
11986        assert_eq!(humanise_ago(Some(59)), "59s ago");
11987        assert_eq!(humanise_ago(Some(60)), "1m ago");
11988        assert_eq!(humanise_ago(Some(3600)), "1h 0m ago");
11989        assert_eq!(humanise_ago(Some(11_460)), "3h 11m ago");
11990    }
11991
11992    /// A sidecar mock that answers `/internal/repo` listRecords with one saved
11993    /// record, and anything else with an empty list. Serves repeatedly.
11994    async fn spawn_saved_sidecar(saved_url: &str, saved_title: &str) -> String {
11995        use tokio::io::{AsyncReadExt, AsyncWriteExt};
11996        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
11997        let addr = listener.local_addr().unwrap();
11998        let (url, title) = (saved_url.to_string(), saved_title.to_string());
11999        tokio::spawn(async move {
12000            loop {
12001                let Ok((mut sock, _)) = listener.accept().await else {
12002                    break;
12003                };
12004                let mut buf = vec![0u8; 8192];
12005                let Ok(n) = sock.read(&mut buf).await else {
12006                    continue;
12007                };
12008                let req = String::from_utf8_lossy(&buf[..n]).to_string();
12009                let wants_saved = req.contains("community.lexicon.rss.saved");
12010                let records = if wants_saved {
12011                    serde_json::json!([{
12012                        "uri": "at://did:plc:x/community.lexicon.rss.saved/rk1",
12013                        "cid": "bafy",
12014                        "value": {
12015                            "$type": "community.lexicon.rss.saved",
12016                            "url": url,
12017                            "title": title,
12018                            "createdAt": "2026-01-01T00:00:00Z"
12019                        }
12020                    }])
12021                } else {
12022                    serde_json::json!([])
12023                };
12024                let body = serde_json::json!({
12025                    "ok": true, "data": { "records": records }
12026                })
12027                .to_string();
12028                let resp = format!(
12029                    "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
12030                    body.len(), body
12031                );
12032                let _ = sock.write_all(resp.as_bytes()).await;
12033                let _ = sock.flush().await;
12034            }
12035        });
12036        format!("http://{addr}")
12037    }
12038
12039    /// A sidecar mock serving `n` distinct saved records, none of them cached
12040    /// locally — the shape that exercises the uncached-row append.
12041    async fn spawn_saved_sidecar_many(n: usize, subscribed_feed: &str) -> String {
12042        let feed = subscribed_feed.to_string();
12043        use tokio::io::{AsyncReadExt, AsyncWriteExt};
12044        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
12045        let addr = listener.local_addr().unwrap();
12046        tokio::spawn(async move {
12047            loop {
12048                let Ok((mut sock, _)) = listener.accept().await else {
12049                    break;
12050                };
12051                let mut buf = vec![0u8; 8192];
12052                let Ok(read) = sock.read(&mut buf).await else {
12053                    continue;
12054                };
12055                let req = String::from_utf8_lossy(&buf[..read]).to_string();
12056                let records = if req.contains("community.lexicon.rss.saved") {
12057                    serde_json::Value::Array(
12058                        (0..n)
12059                            .map(|i| {
12060                                serde_json::json!({
12061                                    "uri": format!("at://did:plc:x/community.lexicon.rss.saved/rk{i}"),
12062                                    "cid": "bafy",
12063                                    "value": {
12064                                        "$type": "community.lexicon.rss.saved",
12065                                        "url": format!("https://elsewhere.example/{i}"),
12066                                        "title": format!("Elsewhere {i}"),
12067                                        "createdAt": "2026-01-01T00:00:00Z"
12068                                    }
12069                                })
12070                            })
12071                            .collect(),
12072                    )
12073                } else if req.contains("community.lexicon.rss.subscription") {
12074                    // Without this the handler's `sync_sub_refs` would REPLACE
12075                    // sub_ref with an empty set on every render, and every
12076                    // sub_ref-scoped read — including the cached starred list
12077                    // this test is about — would come back empty.
12078                    serde_json::json!([{
12079                        "uri": "at://did:plc:x/community.lexicon.rss.subscription/sub1",
12080                        "cid": "bafy",
12081                        "value": {
12082                            "$type": "community.lexicon.rss.subscription",
12083                            "url": feed,
12084                            "createdAt": "2026-01-01T00:00:00Z"
12085                        }
12086                    }])
12087                } else {
12088                    serde_json::json!([])
12089                };
12090                let body =
12091                    serde_json::json!({ "ok": true, "data": { "records": records } }).to_string();
12092                let resp = format!(
12093                    "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
12094                    body.len(), body
12095                );
12096                let _ = sock.write_all(resp.as_bytes()).await;
12097                let _ = sock.flush().await;
12098            }
12099        });
12100        format!("http://{addr}")
12101    }
12102
12103    /// **The pager must not advertise a page the clamp cannot reach.**
12104    ///
12105    /// The page clamp is computed from the CACHED total; the uncached PDS rows
12106    /// are appended to the last page rather than paged. Inflating `total` with
12107    /// them made `page_count` and the "Older →" link point one page past the end:
12108    /// requesting it clamped straight back, re-rendered the same last page, and
12109    /// still offered the link. An infinite "next" that never advances.
12110    #[tokio::test]
12111    async fn the_starred_pager_does_not_advertise_an_unreachable_page() {
12112        let did = "did:plc:pagerloop";
12113        let sidecar = spawn_saved_sidecar_many(80, "https://loop.example/feed.xml").await;
12114        let state = test_state_with_sidecar(&[], &sidecar).await;
12115        store::grant_access(&state.db, did, None, "test", None)
12116            .await
12117            .unwrap();
12118        let feed = store::upsert_feed(
12119            &state.db,
12120            &store::NewFeed {
12121                url: "https://loop.example/feed.xml".to_string(),
12122                title: Some("Loop".to_string()),
12123                ..Default::default()
12124            },
12125        )
12126        .await
12127        .unwrap();
12128        // 250 cached starred entries: the last page holds 50, so 50 + 80 > 100
12129        // and the old arithmetic reported a fourth page.
12130        let entries: Vec<store::NewEntry> = (0..250)
12131            .map(|i| store::NewEntry {
12132                guid: format!("s-{i:04}"),
12133                url: Some(format!("https://loop.example/{i}")),
12134                title: Some(format!("Starred {i:04}")),
12135                published: Some(format!("2026-06-{:02}T00:00:00Z", (i % 28) + 1)),
12136                ..Default::default()
12137            })
12138            .collect();
12139        store::insert_entries(&state.db, feed, &entries, 0)
12140            .await
12141            .unwrap();
12142        store::replace_sub_refs(&state.db, did, &[feed])
12143            .await
12144            .unwrap();
12145        for row in store::list_entries(&state.db, did, store::ListView::All, None, 1_000, 0)
12146            .await
12147            .unwrap()
12148        {
12149            store::mark_starred(&state.db, did, row.id, true)
12150                .await
12151                .unwrap();
12152        }
12153
12154        let cookie = session_cookie(&state, did, None);
12155        let app = router(state.clone());
12156        let get = |uri: &str| {
12157            let (app, cookie, uri) = (app.clone(), cookie.clone(), uri.to_string());
12158            async move {
12159                let resp = app
12160                    .oneshot(
12161                        Request::builder()
12162                            .uri(uri)
12163                            .header(header::COOKIE, cookie)
12164                            .body(Body::empty())
12165                            .unwrap(),
12166                    )
12167                    .await
12168                    .unwrap();
12169                assert_eq!(resp.status(), StatusCode::OK);
12170                String::from_utf8(
12171                    axum::body::to_bytes(resp.into_body(), 16 * 1024 * 1024)
12172                        .await
12173                        .unwrap()
12174                        .to_vec(),
12175                )
12176                .unwrap()
12177            }
12178        };
12179
12180        // 250 cached + 80 uncached = 330 rows over 4 pages. The pager and the
12181        // clamp must agree on that, and EVERY page it offers must have content —
12182        // the original bug advertised a fourth page that clamped back to the
12183        // third and re-rendered it, still offering the link.
12184        let p3 = get("/?view=starred&page=3").await;
12185        assert!(
12186            p3.contains("Page 3 of 4"),
12187            "the pager and the clamp disagree on the total: {}",
12188            p3.split("pager-pos")
12189                .nth(1)
12190                .unwrap_or("")
12191                .chars()
12192                .take(120)
12193                .collect::<String>()
12194        );
12195        // Page 3 is the boundary: the last 50 cached rows, then the first 50
12196        // uncached ones.
12197        assert!(
12198            p3.contains("Elsewhere 0"),
12199            "page 3 should start the uncached run"
12200        );
12201        assert_eq!(
12202            p3.matches("<li class=\"entry").count(),
12203            ENTRIES_PER_PAGE as usize,
12204            "the boundary page is not full"
12205        );
12206
12207        // **The heading, which the previous round broke by deleting this.**
12208        //
12209        // `total` includes the uncached records, so the parenthetical is a
12210        // SUBSET of it, not an addition — "330 entries (80 saved elsewhere)".
12211        // The version that said "plus N" double counted once `total` started
12212        // including them, and N had become page-local in the same commit while
12213        // the template stayed put. It shipped because this assertion was deleted
12214        // rather than updated.
12215        {
12216            let body = &p3;
12217            assert!(
12218                body.contains("330 entries"),
12219                "the heading must count the whole sequence: {}",
12220                body.split("content-count")
12221                    .nth(1)
12222                    .unwrap_or("")
12223                    .chars()
12224                    .take(120)
12225                    .collect::<String>()
12226            );
12227            assert!(
12228                body.contains("(80 saved elsewhere)"),
12229                "the heading must say how many of the total the cache cannot show, \
12230                 as a whole-list figure and not a per-page one: {}",
12231                body.split("content-count")
12232                    .nth(1)
12233                    .unwrap_or("")
12234                    .chars()
12235                    .take(120)
12236                    .collect::<String>()
12237            );
12238            assert!(
12239                !body.contains("plus 50") && !body.contains("plus 80"),
12240                "the heading is adding the uncached rows to a total that already \
12241                 includes them"
12242            );
12243        }
12244
12245        let p4 = get("/?view=starred&page=4").await;
12246        assert!(
12247            p4.contains("Page 4 of 4"),
12248            "page 4 was advertised but clamps somewhere else — the unreachable-page bug"
12249        );
12250        assert_eq!(
12251            p4.matches("<li class=\"entry").count(),
12252            30,
12253            "page 4 should hold the remaining 30 uncached records"
12254        );
12255        assert!(
12256            p4.contains("Elsewhere 79"),
12257            "the LAST saved record is unreachable — it can only be removed from here"
12258        );
12259
12260        // No uncached record appears on two pages.
12261        assert!(
12262            !p4.contains("Elsewhere 0"),
12263            "an uncached record was rendered on more than one page"
12264        );
12265        // Page 1 is all cached — and still reports the same whole-list heading,
12266        // because the parenthetical describes the LIST, not the page.
12267        let first = get("/?view=starred").await;
12268        assert!(
12269            first.contains("330 entries") && first.contains("(80 saved elsewhere)"),
12270            "the heading changed between pages; it describes the list, not the page"
12271        );
12272        assert!(
12273            !first.contains("Elsewhere "),
12274            "uncached saved records leaked onto the first page"
12275        );
12276    }
12277
12278    /// **A saved record whose article is not cached here is still shown.**
12279    ///
12280    /// The starred view is built from local `entries`, so before this a record
12281    /// starred in ANOTHER atproto reader — the portability the shared lexicon
12282    /// exists for — was simply invisible. It now renders from the PDS record,
12283    /// visually distinct, linking straight out.
12284    #[tokio::test]
12285    async fn a_saved_record_with_no_cached_entry_is_shown_as_a_link() {
12286        let did = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
12287        let sidecar =
12288            spawn_saved_sidecar("https://elsewhere.example/article", "Starred elsewhere").await;
12289        let mut state = test_state_with_sidecar(&[did], &sidecar).await;
12290        std::sync::Arc::get_mut(&mut state.config).unwrap().dev_did = Some(did.to_string());
12291
12292        let resp = router(state)
12293            .oneshot(
12294                Request::builder()
12295                    .uri("/?view=starred")
12296                    .body(Body::empty())
12297                    .unwrap(),
12298            )
12299            .await
12300            .unwrap();
12301        assert_eq!(resp.status(), StatusCode::OK);
12302        let body = String::from_utf8(
12303            axum::body::to_bytes(resp.into_body(), usize::MAX)
12304                .await
12305                .unwrap()
12306                .to_vec(),
12307        )
12308        .unwrap();
12309
12310        assert!(
12311            body.contains("Starred elsewhere"),
12312            "the saved record was not rendered at all"
12313        );
12314        assert!(
12315            body.contains("entry-uncached"),
12316            "it was not marked as uncached, so it looks like a normal entry"
12317        );
12318        assert!(
12319            body.contains("https://elsewhere.example/article"),
12320            "the row must link straight to the article"
12321        );
12322        assert!(
12323            !body.contains("/entries/0/"),
12324            "an uncached row must not offer entry actions against a nonexistent id"
12325        );
12326    }
12327
12328    /// **A PDS `createdAt` must not be able to panic the starred view.**
12329    ///
12330    /// `display_date` byte-sliced `p[..10]`. Every prior caller passed a
12331    /// timestamp the feed parser produced; the saved-record path passes a bare
12332    /// string off a PDS record, written by whatever client the reader used. A
12333    /// multi-byte value panicked the handler, and with no catch-panic layer the
12334    /// view stayed down until the record was removed — from that same view.
12335    #[test]
12336    fn a_multibyte_timestamp_does_not_panic_the_date_formatter() {
12337        for hostile in [
12338            "日本語日本語日本",
12339            "é",
12340            "",
12341            "2026",
12342            "🙂🙂🙂🙂🙂🙂🙂🙂🙂🙂🙂",
12343        ] {
12344            let out = display_date(Some(hostile));
12345            assert!(out.chars().count() <= 10, "{hostile:?} -> {out:?}");
12346        }
12347        assert_eq!(display_date(Some("2026-01-01T00:00:00Z")), "2026-01-01");
12348        assert_eq!(display_date(None), "");
12349    }
12350
12351    /// Unsaving makes a DPoP-signed PDS round-trip, which is the stated reason
12352    /// its neighbours are limited. It was added as a route and not added here.
12353    #[test]
12354    fn the_unsave_route_is_rate_limited() {
12355        use axum::http::Method;
12356        assert!(is_rate_limited_path("/saved/3abc/delete", &Method::POST));
12357        // And the neighbours still are.
12358        assert!(is_rate_limited_path("/entries/1/star", &Method::POST));
12359    }
12360
12361    /// **The probe detects a broken database — asserted through `/health`
12362    /// itself, not through a string.**
12363    ///
12364    /// A named constant did not bind the handler: it stayed free to call
12365    /// `query_scalar` with a different literal, so degrading the real probe to
12366    /// `SELECT 1` shipped green twice over. This drops the table the probe reads
12367    /// and asserts the endpoint stops saying `ok` — behaviour no substituted SQL
12368    /// can fake, because `SELECT 1` still succeeds against a wrecked schema.
12369    #[tokio::test]
12370    async fn health_reports_a_broken_database() {
12371        let state = test_state(&[]).await;
12372        // Sanity: healthy first, so the assertion below is about the damage.
12373        assert!(
12374            health_db_probe(&state.db).await.is_ok(),
12375            "the fixture was not healthy to begin with",
12376        );
12377
12378        sqlx::query("DROP TABLE feeds")
12379            .execute(&state.db)
12380            .await
12381            .unwrap();
12382
12383        assert!(
12384            health_db_probe(&state.db).await.is_err(),
12385            "the probe reported success against a database missing the table it \
12386             claims to read; `SELECT 1` would do exactly this",
12387        );
12388
12389        let resp = router(state)
12390            .oneshot(
12391                Request::builder()
12392                    .uri("/health")
12393                    .body(Body::empty())
12394                    .unwrap(),
12395            )
12396            .await
12397            .unwrap();
12398        let body = String::from_utf8(
12399            axum::body::to_bytes(resp.into_body(), usize::MAX)
12400                .await
12401                .unwrap()
12402                .to_vec(),
12403        )
12404        .unwrap();
12405        // The documented contract: the FIRST token is the state.
12406        assert!(
12407            body.starts_with("FAIL"),
12408            "/health did not report FAIL for a broken database: {body}",
12409        );
12410        assert!(
12411            !body.contains("db: ok"),
12412            "/health still called the database ok: {body}",
12413        );
12414    }
12415
12416    /// A sidecar mock for the OPML export: serves one subscription and one
12417    /// folder, except for the collection named in `fail_on`, which answers
12418    /// `500` — the shape a refused (short or unreadable) walk takes at this
12419    /// boundary.
12420    async fn spawn_export_sidecar(fail_on: Option<&'static str>) -> String {
12421        use tokio::io::{AsyncReadExt, AsyncWriteExt};
12422        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
12423        let addr = listener.local_addr().unwrap();
12424        tokio::spawn(async move {
12425            loop {
12426                let Ok((mut sock, _)) = listener.accept().await else {
12427                    break;
12428                };
12429                let mut buf = vec![0u8; 8192];
12430                let Ok(n) = sock.read(&mut buf).await else {
12431                    continue;
12432                };
12433                let req = String::from_utf8_lossy(&buf[..n]).to_string();
12434                let wants = |c: &str| req.contains(c);
12435                if fail_on.is_some_and(wants) {
12436                    let body = r#"{"ok":false,"error":"ShortList"}"#;
12437                    let resp = format!(
12438                        "HTTP/1.1 500 Internal Server Error\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
12439                        body.len(),
12440                        body
12441                    );
12442                    let _ = sock.write_all(resp.as_bytes()).await;
12443                    let _ = sock.flush().await;
12444                    continue;
12445                }
12446                let records = if wants(crate::lexicon::nsid::SUBSCRIPTION) {
12447                    serde_json::json!([{
12448                        "uri": "at://did:plc:exporter/community.lexicon.rss.subscription/sub1",
12449                        "cid": "bafy",
12450                        "value": {
12451                            "$type": crate::lexicon::nsid::SUBSCRIPTION,
12452                            "url": "https://kept.example/feed.xml",
12453                            "title": "Kept",
12454                            // Inside the folder, so the healthy export has to
12455                            // carry BOTH walks' results: an exporter that lost
12456                            // the folder list would flatten this outline out of
12457                            // its group with nothing else changing.
12458                            "folder": "at://did:plc:exporter/community.lexicon.rss.folder/fold1",
12459                            "createdAt": "2026-01-01T00:00:00Z"
12460                        }
12461                    }])
12462                } else if wants(crate::lexicon::nsid::FOLDER) {
12463                    serde_json::json!([{
12464                        "uri": "at://did:plc:exporter/community.lexicon.rss.folder/fold1",
12465                        "cid": "bafy",
12466                        "value": {
12467                            "$type": crate::lexicon::nsid::FOLDER,
12468                            "name": "Kept folder",
12469                            "createdAt": "2026-01-01T00:00:00Z"
12470                        }
12471                    }])
12472                } else {
12473                    serde_json::json!([])
12474                };
12475                let body =
12476                    serde_json::json!({ "ok": true, "data": { "records": records } }).to_string();
12477                let resp = format!(
12478                    "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
12479                    body.len(),
12480                    body
12481                );
12482                let _ = sock.write_all(resp.as_bytes()).await;
12483                let _ = sock.flush().await;
12484            }
12485        });
12486        format!("http://{addr}")
12487    }
12488
12489    /// A sidecar whose every `listRecords` page carries one good record and
12490    /// one with no `uri` — the #177 shape — for any collection.
12491    async fn spawn_malformed_sidecar() -> String {
12492        use tokio::io::{AsyncReadExt, AsyncWriteExt};
12493        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
12494        let addr = listener.local_addr().unwrap();
12495        tokio::spawn(async move {
12496            loop {
12497                let Ok((mut sock, _)) = listener.accept().await else {
12498                    break;
12499                };
12500                let mut buf = vec![0u8; 8192];
12501                let _ = sock.read(&mut buf).await;
12502                let body = serde_json::json!({ "ok": true, "data": { "records": [
12503                    { "uri": "at://did:plc:alerted/c/3labGOOD", "cid": "bafy", "value": {} },
12504                    { "cid": "bafy", "value": {} },
12505                ]}})
12506                .to_string();
12507                let resp = format!(
12508                    "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
12509                    body.len(),
12510                    body
12511                );
12512                let _ = sock.write_all(resp.as_bytes()).await;
12513                let _ = sock.flush().await;
12514            }
12515        });
12516        format!("http://{addr}")
12517    }
12518
12519    async fn page_body(state: AppState, did: &str, uri: &str) -> (StatusCode, String) {
12520        let cookie = session_cookie(&state, did, None);
12521        let resp = router(state)
12522            .oneshot(
12523                Request::builder()
12524                    .uri(uri)
12525                    .header(header::COOKIE, cookie)
12526                    .body(Body::empty())
12527                    .unwrap(),
12528            )
12529            .await
12530            .unwrap();
12531        let status = resp.status();
12532        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
12533            .await
12534            .unwrap();
12535        (status, String::from_utf8_lossy(&body).to_string())
12536    }
12537
12538    /// **0.4.0 step 4: a publication document with neither summary field
12539    /// renders as a title, a date and a link** — 8% of measured documents
12540    /// (37 of 449) carry neither `description` nor `textContent`. That is what
12541    /// an RSS reader shows for a title-only feed, not an error, in the list and
12542    /// on the article page alike.
12543    #[tokio::test]
12544    async fn a_publication_entry_with_no_summary_renders_title_date_and_link() {
12545        let did = "did:plc:displayer";
12546        let state = test_state(&[did]).await;
12547        let url = "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lab";
12548        let feed_id = store::upsert_feed(
12549            &state.db,
12550            &store::NewFeed {
12551                url: url.into(),
12552                title: Some("Quiet Journal".into()),
12553                ..Default::default()
12554            },
12555        )
12556        .await
12557        .unwrap();
12558        store::replace_sub_refs(&state.db, did, &[feed_id])
12559            .await
12560            .unwrap();
12561        store::insert_entries(
12562            &state.db,
12563            feed_id,
12564            &[store::NewEntry {
12565                guid: "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.document/3l2nosumaaa2a"
12566                    .into(),
12567                url: Some("https://quiet.example/no-summary".into()),
12568                title: Some("A title-only article".into()),
12569                published: Some("2026-07-11T00:00:00Z".into()),
12570                content_html: None,
12571                ..Default::default()
12572            }],
12573            0,
12574        )
12575        .await
12576        .unwrap();
12577        let (status, list) = page_body(state.clone(), did, "/?view=all").await;
12578        assert_eq!(status, StatusCode::OK);
12579        assert!(
12580            list.contains("A title-only article"),
12581            "the entry is missing from the list"
12582        );
12583
12584        let id: i64 = sqlx::query_scalar("SELECT id FROM entries WHERE feed_id = ?")
12585            .bind(feed_id)
12586            .fetch_one(&state.db)
12587            .await
12588            .unwrap();
12589        let (status, page) = page_body(state, did, &format!("/entries/{id}")).await;
12590        assert_eq!(
12591            status,
12592            StatusCode::OK,
12593            "the article page failed for an entry with no body"
12594        );
12595        assert!(page.contains("A title-only article"));
12596        assert!(
12597            page.contains("https://quiet.example/no-summary"),
12598            "no link to the original"
12599        );
12600        assert!(
12601            page.contains(r#"<time datetime=""#),
12602            "no date on the article page"
12603        );
12604    }
12605
12606    /// **#177: a malformed record in the reader's own repo is refused, and the
12607    /// reader is told.** Refusing keeps `replace_sub_refs` from dropping the
12608    /// subscription that record was; telling them keeps the stale list from
12609    /// looking like the real one. Both the reading page and the manage page.
12610    #[tokio::test]
12611    async fn a_malformed_subscription_record_raises_an_alert() {
12612        let did = "did:plc:alerted";
12613        for page in ["/", "/manage"] {
12614            let sidecar = spawn_malformed_sidecar().await;
12615            let state = test_state_with_sidecar(&[did], &sidecar).await;
12616            let (status, body) = page_body(state, did, page).await;
12617            assert_eq!(status, StatusCode::OK, "{page} did not render");
12618            assert!(
12619                body.contains(r#"role="alert""#) && body.contains("could not be read"),
12620                "{page} rendered no alert for a refused subscription list"
12621            );
12622            assert!(
12623                body.contains("1 record(s) in your subscription list"),
12624                "{page} gave the generic alert, not the malformed-record one"
12625            );
12626        }
12627    }
12628
12629    /// The control: a healthy listing raises no alert.
12630    #[tokio::test]
12631    async fn a_healthy_subscription_listing_raises_no_alert() {
12632        let did = "did:plc:exporter";
12633        let sidecar = spawn_export_sidecar(None).await;
12634        let state = test_state_with_sidecar(&[did], &sidecar).await;
12635        let (status, body) = page_body(state, did, "/").await;
12636        assert_eq!(status, StatusCode::OK);
12637        assert!(
12638            !body.contains("could not be read"),
12639            "a healthy listing raised an alert"
12640        );
12641    }
12642
12643    /// `GET /opml/export` against the mock, returning `(status, headers, body)`.
12644    async fn export_opml_response(
12645        fail_on: Option<&'static str>,
12646    ) -> (StatusCode, HeaderMap, String) {
12647        let did = "did:plc:exporter";
12648        let sidecar = spawn_export_sidecar(fail_on).await;
12649        let state = test_state_with_sidecar(&[did], &sidecar).await;
12650        let cookie = session_cookie(&state, did, None);
12651        let resp = router(state)
12652            .oneshot(
12653                Request::builder()
12654                    .uri("/opml/export")
12655                    .header(header::COOKIE, cookie)
12656                    .body(Body::empty())
12657                    .unwrap(),
12658            )
12659            .await
12660            .unwrap();
12661        let status = resp.status();
12662        let headers = resp.headers().clone();
12663        let body = String::from_utf8_lossy(
12664            &axum::body::to_bytes(resp.into_body(), usize::MAX)
12665                .await
12666                .unwrap(),
12667        )
12668        .to_string();
12669        (status, headers, body)
12670    }
12671
12672    /// **An empty export is worse than no export, and this is the caller that
12673    /// used to produce one.**
12674    ///
12675    /// `export_opml` read both walks through `unwrap_or_default()`. Now that a
12676    /// truncated walk refuses instead of returning a short list, that turned the
12677    /// refusal into `200 OK` carrying a zero-feed
12678    /// `featherreader-subscriptions.opml` — a blank backup handed over at exactly
12679    /// the moment a locked-out reader reached for one, and the changelog points
12680    /// them at this route as the recovery path.
12681    ///
12682    /// Asserts the three things a reader can actually observe: no success status,
12683    /// no download offered, and no OPML document in the body.
12684    #[tokio::test]
12685    async fn an_export_that_cannot_read_the_subscriptions_serves_no_opml() {
12686        let (status, headers, body) =
12687            export_opml_response(Some(crate::lexicon::nsid::SUBSCRIPTION)).await;
12688
12689        assert_ne!(
12690            status,
12691            StatusCode::OK,
12692            "a failed subscription walk answered 200: {body}",
12693        );
12694        assert!(
12695            !headers.contains_key(header::CONTENT_DISPOSITION),
12696            "a failed subscription walk still offered a download: {headers:?}",
12697        );
12698        assert!(
12699            !body.contains("<opml"),
12700            "a failed subscription walk still served an OPML document: {body}",
12701        );
12702    }
12703
12704    /// The folders half of the same hole. The two walks are separate calls, and
12705    /// fixing only the first leaves an export that silently loses every folder —
12706    /// a flat list that reimports as one, with no sign anything was lost.
12707    #[tokio::test]
12708    async fn an_export_that_cannot_read_the_folders_serves_no_opml() {
12709        let (status, headers, body) =
12710            export_opml_response(Some(crate::lexicon::nsid::FOLDER)).await;
12711
12712        assert_ne!(
12713            status,
12714            StatusCode::OK,
12715            "a failed folder walk answered 200: {body}",
12716        );
12717        assert!(
12718            !headers.contains_key(header::CONTENT_DISPOSITION),
12719            "a failed folder walk still offered a download: {headers:?}",
12720        );
12721        assert!(
12722            !body.contains("<opml"),
12723            "a failed folder walk still served an OPML document: {body}",
12724        );
12725    }
12726
12727    /// The other direction, without which "refuse everything" would pass both
12728    /// tests above: a healthy read still serves the file, with the feed in it.
12729    #[tokio::test]
12730    async fn a_healthy_export_serves_the_subscriptions_as_a_download() {
12731        let (status, headers, body) = export_opml_response(None).await;
12732
12733        assert_eq!(
12734            status,
12735            StatusCode::OK,
12736            "a healthy export did not answer 200"
12737        );
12738        assert_eq!(
12739            headers
12740                .get(header::CONTENT_DISPOSITION)
12741                .and_then(|v| v.to_str().ok()),
12742            Some("attachment; filename=\"featherreader-subscriptions.opml\""),
12743            "a healthy export did not offer the download",
12744        );
12745        assert!(
12746            body.contains("https://kept.example/feed.xml"),
12747            "the exported OPML lost the subscription: {body}",
12748        );
12749        assert!(
12750            body.contains("Kept folder"),
12751            "the exported OPML lost the folder: {body}",
12752        );
12753    }
12754}