Skip to main content

feather_reader/
web.rs

1//! The axum web layer — server-rendered HTML + a dash of htmx, **no SPA**.
2//!
3//! This module owns the HTTP surface: [`router`] builds an `axum::Router` over
4//! the shared [`AppState`], wiring the store, feed, atproto, and config seams into
5//! a small set of typography-first, dark-mode-ready views rendered with
6//! `askama` templates (under `templates/`). Progressive enhancement is a single
7//! vendored `htmx` script plus a tiny keyboard handler (`static/keyboard.js`);
8//! every interaction also works as a plain HTML form POST, so the reader is fully
9//! usable with JavaScript disabled.
10//!
11//! ## HTTP surface
12//!
13//! * `GET  /health` — liveness + version, as `text/plain`.
14//! * `GET  /` — the reader: a folders/feeds sidebar (from the PDS records layer)
15//!   plus the main article list. Query params pick the scope (`?feed=…` /
16//!   `?folder=…` / all) and the view (`?view=unread|all|starred`).
17//! * `GET  /entries/{id}` — the clean, distraction-free reader for one entry,
18//!   with prev/next within the current list.
19//! * `POST /entries/{id}/read` — mark an entry read/unread (htmx row swap).
20//! * `POST /entries/{id}/star` — star/unstar; writes a
21//!   `community.lexicon.rss.saved` record to the user's PDS.
22//! * `POST /read-all` — mark-all-read (per feed via `?feed=…`, else everything).
23//! * `POST /subscriptions` — subscribe by URL (autodiscover → PDS record).
24//! * `POST /subscriptions/{rkey}/delete` — unsubscribe (delete the PDS record).
25//! * `POST /subscriptions/{rkey}/rename` — retitle / move a feed to a folder.
26//! * `POST /folders` — create a folder record.
27//! * `POST /folders/{rkey}/rename` — rename a folder record.
28//! * `POST /folders/{rkey}/delete` — delete a folder record.
29//! * `POST /opml` — OPML import (multipart upload *or* pasted textarea) → bulk
30//!   subscription records in the PDS.
31//! * `GET  /opml/export` — OPML export (records → a downloadable document).
32//! * `GET /login` + `POST /login` + `/oauth/callback` + `/logout` — the atproto
33//!   OAuth sign-in flow (routed through the sidecar).
34//! * `GET /claim?t=<token>` — the follow→invite bot's claim link: an opaque token
35//!   reserving a pre-minted invite code; behaves like a successful `/beta/redeem`
36//!   (sets the reserving cookie → `/login`).
37//! * `POST /bot/claims` — headless, shared-secret (`X-Bot-Secret`) mint of a claim
38//!   code + token/url for the bot to post. Cap-aware (409 when full).
39//!
40//! ## Identity — a cookie-resolved atproto session
41//!
42//! Per-request identity comes from a **signed session cookie** (`fr_session`)
43//! keyed by the logged-in DID, set by `oauth_callback` and read by
44//! `current_session` / `current_did`. For local runs without the sidecar,
45//! [`Config::dev_did`] (env `FEATHERREADER_DEV_DID`) supplies a fallback identity.
46//! All PDS writes route through the [`crate::atproto::SidecarClient`]; a live-PDS
47//! write needs a real OAuth session, but the full write path is built and unit-
48//! tested to the sidecar boundary.
49
50use std::collections::HashMap;
51use std::net::IpAddr;
52use std::sync::Mutex;
53use std::time::{Duration, Instant};
54
55use askama::Template;
56use axum::{
57    extract::{ConnectInfo, DefaultBodyLimit, Multipart, Path, Query, State},
58    http::{header, HeaderMap, StatusCode},
59    middleware::{self, Next},
60    response::{Html, IntoResponse, Redirect, Response},
61    routing::{get, post},
62    Form, Router,
63};
64use serde::Deserialize;
65use std::net::SocketAddr;
66use tower_http::services::{ServeDir, ServeFile};
67use tower_http::set_header::SetResponseHeaderLayer;
68use tower_http::trace::TraceLayer;
69use tracing::{info, warn};
70
71use crate::config::Config;
72use crate::lexicon::{self, Folder, Saved, Subscription};
73use crate::safe_link::SafeLink;
74use crate::{feed, store, AppState, Session, VERSION};
75
76// The OPML import/export module lives at `src/opml.rs` but isn't declared in the
77// crate root (`lib.rs`), which is outside this phase's edit surface. Wire it in
78// here via an explicit path so the reader's OPML routes can use the canonical
79// `parse_opml` / `to_opml` without duplicating that logic.
80#[path = "opml.rs"]
81mod opml;
82
83/// The name of the signed session cookie.
84const SESSION_COOKIE: &str = "fr_session";
85
86/// The name of the short-lived signed **invite** cookie.
87///
88/// Set by `POST /beta/redeem` on a valid, capacity-ok code and consumed by the
89/// OAuth callback. It reserves *intent* to redeem a specific code before the
90/// visitor ever starts the OAuth handshake, so a non-invited visitor can't burn
91/// a sidecar handshake (pre-handshake gate). It carries the invite code, HMAC-
92/// signed with the same key as the session cookie.
93const INVITE_COOKIE: &str = "fr_invite";
94
95/// Browser-binding cookie for an in-flight OAuth login (Rust backend only).
96///
97/// `state` alone cannot stop a login CSRF: it lives in a server-global table, so
98/// a stolen `state` replayed from ANOTHER browser matches just as well as from
99/// the one that started the flow. This cookie is what makes the callback
100/// browser-specific — the pending row stores only its hash, and a callback that
101/// cannot present it is refused.
102const OAUTH_BINDING_COOKIE: &str = "fr_oauth";
103
104/// How long an in-flight login may sit, matching the pending row's own TTL.
105const OAUTH_BINDING_MAX_AGE_SECS: i64 = 600;
106
107/// TTL (seconds) for a minted invite code and for the reserving invite cookie.
108/// Short enough that a reserved-but-unclaimed seat frees quickly.
109const INVITE_TTL_SECS: i64 = 1800;
110
111/// The canonical AGPL-3.0 source repository — surfaced in the footer, the
112/// sign-in pitch, and `/about`.
113const REPO_URL: &str = "https://github.com/justin-stanley/feather-reader";
114
115/// The tip / support link (cloud plan public-experiment UI).
116const KOFI_URL: &str = "https://ko-fi.com/justinstanley";
117
118/// The published crate on crates.io — surfaced on the signed-out landing page.
119const CRATES_URL: &str = "https://crates.io/crates/feather-reader";
120
121/// The Content-Security-Policy applied to every response.
122///
123/// Tuned to keep the app fully working while neutralising injected script:
124/// * `default-src 'self'` — same-origin baseline.
125/// * `script-src 'self'` — only our vendored `htmx.min.js` + `keyboard.js` from
126///   `/static`; **no** `'unsafe-inline'`, so an injected `<script>` or a
127///   `javascript:` href (F4) cannot execute. (The design's templates carry no
128///   inline event handlers — every control is wired in `keyboard.js`.)
129/// * `style-src 'self' 'unsafe-inline'` — the linked stylesheet plus the small
130///   inline styles htmx toggles for its request indicators.
131/// * `img-src 'self' https: data:` — feed content routinely embeds remote
132///   images; allow https + data URIs but not other schemes.
133/// * `form-action 'self'`, `base-uri 'self'`, `frame-ancestors 'none'` — lock
134///   down form posts, `<base>` hijacking, and clickjacking.
135/// * `object-src 'none'` — no plugins.
136const CONTENT_SECURITY_POLICY: &str = "default-src 'self'; \
137     script-src 'self'; \
138     style-src 'self' 'unsafe-inline'; \
139     img-src 'self' https: data:; \
140     font-src 'self'; \
141     connect-src 'self'; \
142     form-action 'self'; \
143     base-uri 'self'; \
144     frame-ancestors 'none'; \
145     object-src 'none'";
146
147/// The resolved identity for the current request.
148///
149/// `did` is the primary key for all per-user local state; `handle` is display
150/// only; `sid` is the opaque server-side session id the cookie carried (needed
151/// so logout can revoke exactly this session). Sourced from the signed cookie
152/// (real login) or, if none, the configured dev DID fallback.
153#[derive(Clone, Debug)]
154struct CurrentUser {
155    did: String,
156    handle: Option<String>,
157    /// The opaque session id, if this identity came from a real cookie session
158    /// (absent for the dev-DID fallback, which has no server-side session row).
159    sid: Option<String>,
160}
161
162/// Resolve the current request's session from the signed cookie, falling back to
163/// the configured dev DID (env `FEATHERREADER_DEV_DID`) for local runs.
164///
165/// The cookie carries an opaque server-minted session id (not the DID). We
166/// verify its HMAC, look the id up in the registry, and — crucially —
167/// **re-check the DID against the closed-beta gate on every request**
168/// ([`store::has_beta_access`]), not just at the OAuth callback, so revoking a
169/// DID's beta seat takes effect immediately for already-issued cookies. (The
170/// gate replaced the old static `ALLOWED_DIDS` check; `ALLOWED_DIDS` remains the
171/// admin-bootstrap seed, granted a seat at startup via `ensure_seed`.)
172async fn current_session(state: &AppState, headers: &HeaderMap) -> Option<CurrentUser> {
173    if let Some(sid) = cookie::verify_session(headers, &state.config.cookie_secret) {
174        if let Some(session) = state.sessions.get(&sid) {
175            if store::has_beta_access(&state.db, &session.did)
176                .await
177                .unwrap_or(false)
178            {
179                return Some(CurrentUser {
180                    did: session.did,
181                    handle: session.handle,
182                    sid: Some(sid),
183                });
184            }
185            // DID no longer holds a beta seat: treat as logged out (and drop the
186            // stale server-side session so the dead cookie can't linger).
187            state.sessions.remove(&sid);
188        }
189    }
190    // No valid cookie: dev fallback only if explicitly configured *and* still
191    // inside the beta gate (seeded via ensure_seed / a redeemed code).
192    if let Some(did) = state.config.dev_did.clone() {
193        if store::has_beta_access(&state.db, &did)
194            .await
195            .unwrap_or(false)
196        {
197            return Some(CurrentUser {
198                did,
199                handle: None,
200                sid: None,
201            });
202        }
203    }
204    None
205}
206
207/// The current request's DID, or `None` when logged out (no cookie, no dev DID).
208async fn current_did(state: &AppState, headers: &HeaderMap) -> Option<String> {
209    current_session(state, headers).await.map(|u| u.did)
210}
211
212/// Build the application router over shared [`AppState`].
213///
214/// Wires the reader routes, the health check, and the `/static` asset mount
215/// (the stylesheet, vendored htmx, and the keyboard handler, served from
216/// `static/` via `ServeDir`). A `TraceLayer` gives per-request tracing.
217pub fn router(state: AppState) -> Router {
218    // The shared per-IP rate limiter for the abuse-prone paths (login, redeem,
219    // and the write endpoints). One instance is cloned into the state closure of
220    // the `rate_limit` middleware.
221    let limiter = RateLimiter::shared();
222    // The trusted client-IP source for the limiter (a proxy header the operator
223    // controls, or the socket peer when unset). Bundled with the limiter so the
224    // middleware derives a spoof-resistant IP.
225    let rl_state = RateLimitState {
226        limiter,
227        trusted_header: state.config.trusted_ip_header.clone(),
228    };
229
230    Router::new()
231        .route("/health", get(health))
232        .route("/about", get(about))
233        .route("/stats", get(stats))
234        .route("/privacy", get(privacy))
235        .route("/terms", get(terms))
236        .route("/manage", get(manage))
237        .route("/", get(index))
238        .route("/entries/{id}", get(entry_view))
239        .route("/entries/{id}/read", post(mark_read))
240        .route("/entries/{id}/star", post(toggle_star))
241        .route("/saved/{rkey}/delete", post(unsave_record))
242        .route("/read-all", post(mark_all_read))
243        .route("/subscriptions", post(add_subscription))
244        .route("/subscriptions/{rkey}/delete", post(delete_subscription))
245        .route("/subscriptions/{rkey}/rename", post(rename_subscription))
246        .route("/folders", post(create_folder))
247        .route("/folders/{rkey}/rename", post(rename_folder))
248        .route("/folders/{rkey}/delete", post(delete_folder))
249        // OPML import takes untrusted uploads: cap the body so a huge upload
250        // can't OOM (residual body-cap), on top of the streamed feed-fetch cap.
251        .route(
252            "/opml",
253            post(import_opml).layer(DefaultBodyLimit::max(OPML_BODY_LIMIT)),
254        )
255        .route("/opml/export", get(export_opml))
256        .route("/login", get(login_form).post(login_submit))
257        .route(
258            "/beta/redeem",
259            get(beta_redeem_form).post(beta_redeem_submit),
260        )
261        // The follow→invite bot's claim link: a public skeet points a new
262        // follower here with an opaque token that reserves a pre-minted code.
263        .route("/claim", get(claim))
264        // Headless bot mint endpoint (shared-secret, not OAuth). Mints a claim
265        // code + returns its token/url for the bot to post.
266        .route("/bot/claims", post(bot_mint_claim))
267        .route("/admin/invites", post(admin_mint_invites))
268        .route("/admin/metrics", get(admin_metrics))
269        .route("/oauth/client-metadata.json", get(oauth_client_metadata))
270        .route("/oauth/jwks.json", get(oauth_jwks))
271        .route("/account/delete", post(account_delete))
272        .route("/oauth/callback", get(oauth_callback))
273        .route("/logout", post(logout))
274        .nest_service("/static", ServeDir::new("static"))
275        // Browsers (and some feed clients) request /favicon.ico at the root
276        // regardless of the <link rel="icon"> tags; serve the same icon that
277        // lives under /static so the bare path stops 404-ing.
278        .route_service("/favicon.ico", ServeFile::new("static/favicon.ico"))
279        // Cache-Control (viral/CDN plan): `public, max-age=300` on the cacheable
280        // logged-out landing + static assets, `no-store` on anything that
281        // rendered a session's private view. Runs *inside* the security layers so
282        // the CSP/nosniff/frame headers are untouched.
283        .layer(middleware::from_fn(cache_control))
284        // Per-IP rate limit on the abuse-prone paths (429 over the limit). Runs
285        // as a middleware so it sees the matched path + the peer IP.
286        .layer(middleware::from_fn_with_state(rl_state, rate_limit))
287        .layer(TraceLayer::new_for_http())
288        // Baseline security headers on *every* response (F4). The CSP is the
289        // backstop that neutralises any XSS that slips past sanitization; the
290        // others harden sniffing, framing, and referrer leakage.
291        .layer(static_header_layer(
292            "content-security-policy",
293            CONTENT_SECURITY_POLICY,
294        ))
295        .layer(static_header_layer("x-content-type-options", "nosniff"))
296        .layer(static_header_layer(
297            "referrer-policy",
298            "strict-origin-when-cross-origin",
299        ))
300        .layer(static_header_layer("x-frame-options", "DENY"))
301        .with_state(state)
302}
303
304/// Body-size ceiling for the OPML import upload: **1 MiB, deliberately below
305/// axum's 2 MiB default.**
306///
307/// The value used to BE the framework default, which made the route's own
308/// `DefaultBodyLimit` layer a no-op: removing the layer changed nothing, so
309/// nothing could test it, and the ceiling this route wanted was whatever the
310/// framework happened to pick. Sized to this route instead — one outline is
311/// ~92 bytes, so 1 MiB carries ~11 000 of them against a per-DID cap
312/// (`max_subs_per_did`, default 500) the import trims to anyway. Anything
313/// larger is not a subscription list.
314///
315/// Being strictly tighter than the default is what makes the layer both real
316/// and pinnable: `opml_import_over_the_route_cap_is_refused_below_the_framework_default`
317/// uploads a payload that only this limit refuses.
318const OPML_BODY_LIMIT: usize = 1024 * 1024;
319
320/// axum's own `DefaultBodyLimit` (2 MiB as of axum 0.8), for the test that
321/// uploads a payload between the two ceilings.
322///
323/// **What matters is only that it EXCEEDS [`OPML_BODY_LIMIT`]**, not that this
324/// number is exact — axum does not export it, so it cannot be imported. The
325/// exceeding is what the test's mutation demonstrates: with the route's layer
326/// removed, a payload of this size is accepted. If axum ever lowers its
327/// default below ours, that mutation stops failing and the compile-time
328/// assertion below is the thing to revisit.
329#[cfg(test)]
330const AXUM_DEFAULT_BODY_LIMIT: usize = 2 * 1024 * 1024;
331
332/// The route's cap must stay strictly tighter than the framework's, or its
333/// layer is a no-op again. A compile error, not a test failure: this is a
334/// property of the two constants, and nothing should be able to build a binary
335/// where it is false.
336#[cfg(test)]
337const _: () = assert!(
338    OPML_BODY_LIMIT < AXUM_DEFAULT_BODY_LIMIT,
339    "OPML_BODY_LIMIT must be tighter than axum's default, or the route's layer does nothing"
340);
341
342/// A response-header layer that sets `name: value` on every response, overriding
343/// any existing header of that name. `name`/`value` must be valid static header
344/// tokens (they are, for our fixed security headers).
345fn static_header_layer(
346    name: &'static str,
347    value: &'static str,
348) -> SetResponseHeaderLayer<header::HeaderValue> {
349    SetResponseHeaderLayer::overriding(
350        header::HeaderName::from_static(name),
351        header::HeaderValue::from_static(value),
352    )
353}
354
355// ---------------------------------------------------------------------------
356// Per-IP rate limiting (token bucket, self-contained — no extra crate)
357// ---------------------------------------------------------------------------
358
359/// The abuse-prone paths the rate limiter guards (429 over the limit): the OAuth
360/// kick-off and callback, the invite redeem, logout, the mutating write
361/// endpoints, and mark-read/star/mark-all. Plain read-only navigation is
362/// intentionally *not* limited.
363///
364/// The criterion is **does this path make an outbound request**, not "does it
365/// mutate" — the two diverge, and every miss so far has been on the outbound
366/// side. This is an allowlist a new route has to be added to by hand, which is
367/// exactly why it has now been missed three times: `/saved/` (fixed), then
368/// `/oauth/callback` and `/logout`. The callback was the bad one — it is the
369/// only path here reachable with no session at all.
370///
371/// Known and deliberate gaps: `GET /`, `GET /manage` and `GET /opml/export` each
372/// make PDS calls but are ordinary authenticated navigation, and throttling them
373/// would degrade normal reading. They are bounded by needing a valid session.
374fn is_rate_limited_path(path: &str, method: &axum::http::Method) -> bool {
375    use axum::http::Method;
376    // `/claim` is a GET (a link the bot posts), but it consumes a reservation and
377    // a claim token in a public URL is grabbable, so it MUST be per-IP limited
378    // like the other abuse-prone entry points — not just `/login`.
379    // `/oauth/callback` is a GET, is UNAUTHENTICATED, and every hit performs a
380    // real outbound round-trip — a sidecar `resolve_session` or a full token
381    // exchange against a PDS. Anyone could spend one outbound request per hit.
382    // It is the only entry point here that needs no session at all.
383    if method != Method::POST
384        && !(method == Method::GET
385            && (path == "/login" || path == "/claim" || path == "/oauth/callback"))
386    {
387        return false;
388    }
389    match path {
390        // `/logout` and `/oauth/callback` are here because they make outbound
391        // calls, not because they mutate: logout revokes at the PDS (up to two
392        // round-trips) and the callback exchanges a code. The list is by
393        // *network cost*, which is what the limiter is actually for.
394        "/login" | "/claim" | "/oauth/callback" | "/logout" | "/beta/redeem" | "/subscriptions"
395        | "/opml" | "/read-all" | "/admin/invites" | "/bot/claims" | "/account/delete"
396        | "/folders" => true,
397        // Every per-record subscription/folder mutation (delete/rename) and the
398        // star/mark-read taps make a sidecar/PDS round-trip, so limit them too.
399        p => {
400            (p.starts_with("/entries/") && (p.ends_with("/read") || p.ends_with("/star")))
401                // Unsaving makes a DPoP-signed deleteRecord round-trip to the
402                // PDS, which is exactly the reason the neighbours above are
403                // limited. It was added as a new route and not added here.
404                || p.starts_with("/saved/")
405                || p.starts_with("/subscriptions/")
406                || p.starts_with("/folders/")
407        }
408    }
409}
410
411/// Middleware state for [`rate_limit`]: the shared limiter plus the trusted
412/// client-IP header (if any). Cloned into every request; both fields are cheap.
413#[derive(Clone)]
414struct RateLimitState {
415    limiter: RateLimiter,
416    /// The lowercased proxy header the operator trusts for the client IP, or
417    /// `None` to trust only the socket peer. See [`client_ip`].
418    trusted_header: Option<String>,
419}
420
421/// A tiny per-IP token-bucket rate limiter. Each IP gets [`RATE_BURST`] tokens
422/// that refill at [`RATE_REFILL_PER_SEC`]/sec; a request costs one token and is
423/// rejected (429) when the bucket is empty. Self-contained (no `tower_governor`
424/// dependency → no network fetch at build, deterministic offline CI).
425#[derive(Clone)]
426struct RateLimiter {
427    inner: std::sync::Arc<Mutex<RateLimiterState>>,
428}
429
430/// The limiter's shared state: the buckets plus when they were last swept.
431struct RateLimiterState {
432    buckets: HashMap<IpAddr, Bucket>,
433    last_sweep: Instant,
434}
435
436/// One IP's token bucket: a fractional token count + the last-refill instant.
437struct Bucket {
438    tokens: f64,
439    last: Instant,
440}
441
442/// Burst capacity per IP — how many requests can arrive back-to-back.
443const RATE_BURST: f64 = 20.0;
444/// Steady-state refill rate (tokens/sec) once the burst is spent.
445const RATE_REFILL_PER_SEC: f64 = 1.0;
446/// Evict idle buckets older than this so the map can't grow unbounded.
447const RATE_IDLE_EVICT: Duration = Duration::from_secs(3600);
448
449/// How often the idle sweep may actually run.
450///
451/// The sweep used to run on EVERY guarded request — an O(n) scan of the whole
452/// map to find entries that, by construction, can only age out on an hour
453/// boundary. `GET /login` and `GET /claim` are guarded and unauthenticated, so
454/// under any volume of distinct source IPs the server spent its single shared
455/// core re-walking a map whose contents had not changed. Once a minute is
456/// plenty: it bounds bucket lifetime at `RATE_IDLE_EVICT + RATE_SWEEP_EVERY`.
457const RATE_SWEEP_EVERY: Duration = Duration::from_secs(60);
458
459/// Most buckets kept. At roughly 100 bytes each this is ~1 MB — a bound, not a
460/// target, sized so ordinary traffic never reaches it.
461///
462/// The idle eviction above was the only bound, and it is a TIME bound, which
463/// says nothing about how many distinct IPs can arrive inside one hour.
464/// `net.rs` bounds the equivalent structure by count (`MAX_PINNED_CLIENTS`);
465/// this one did not.
466const MAX_RATE_BUCKETS: usize = 10_000;
467
468/// When the cap is hit, evict down to this fraction of it rather than removing
469/// a single entry — so the O(n) eviction happens once per `cap/8` requests
470/// instead of once per request while the map sits full.
471const RATE_EVICT_DOWN_TO: usize = MAX_RATE_BUCKETS * 7 / 8;
472
473impl RateLimiter {
474    /// A fresh, shared limiter (cloned into the middleware state).
475    fn shared() -> Self {
476        Self {
477            inner: std::sync::Arc::new(Mutex::new(RateLimiterState {
478                buckets: HashMap::new(),
479                last_sweep: Instant::now(),
480            })),
481        }
482    }
483
484    /// Charge one token for `ip`; returns `true` if allowed, `false` if the
485    /// bucket is empty (→ 429).
486    fn check(&self, ip: IpAddr) -> bool {
487        self.check_at(ip, Instant::now())
488    }
489
490    /// [`check`](Self::check) with the clock injected, so the sweep and eviction
491    /// paths below are reachable in a test without sleeping through an hour.
492    fn check_at(&self, ip: IpAddr, now: Instant) -> bool {
493        let mut state = match self.inner.lock() {
494            Ok(m) => m,
495            // A poisoned lock shouldn't take the site down — fail open.
496            Err(p) => p.into_inner(),
497        };
498
499        // Idle sweep, at most once per `RATE_SWEEP_EVERY`.
500        if now.duration_since(state.last_sweep) >= RATE_SWEEP_EVERY {
501            state
502                .buckets
503                .retain(|_, b| now.duration_since(b.last) < RATE_IDLE_EVICT);
504            state.last_sweep = now;
505        }
506
507        // Hard size bound, independent of the time bound above.
508        //
509        // Evicting LEAST-RECENTLY-USED is what makes this safe to do at all. An
510        // attacker cannot use eviction to clear their OWN throttled bucket: that
511        // bucket is by definition the most recently touched, so it is the last
512        // thing this removes. Going quiet long enough to become the oldest entry
513        // is exactly what the refill already grants for free.
514        if state.buckets.len() >= MAX_RATE_BUCKETS && !state.buckets.contains_key(&ip) {
515            let mut by_age: Vec<(IpAddr, Instant)> =
516                state.buckets.iter().map(|(k, b)| (*k, b.last)).collect();
517            by_age.sort_unstable_by_key(|(_, last)| *last);
518            for (victim, _) in by_age
519                .into_iter()
520                .take(state.buckets.len().saturating_sub(RATE_EVICT_DOWN_TO))
521            {
522                state.buckets.remove(&victim);
523            }
524            warn!(
525                buckets = state.buckets.len(),
526                "rate-limit bucket cap reached; evicted the least recently seen clients"
527            );
528        }
529
530        let bucket = state.buckets.entry(ip).or_insert(Bucket {
531            tokens: RATE_BURST,
532            last: now,
533        });
534        let elapsed = now.duration_since(bucket.last).as_secs_f64();
535        bucket.tokens = (bucket.tokens + elapsed * RATE_REFILL_PER_SEC).min(RATE_BURST);
536        bucket.last = now;
537        if bucket.tokens >= 1.0 {
538            bucket.tokens -= 1.0;
539            true
540        } else {
541            false
542        }
543    }
544}
545
546/// The **trusted** client IP for a request.
547///
548/// Security: a naive limiter that trusts the *left-most* `X-Forwarded-For` hop
549/// is fully bypassable — the left-most value is attacker-supplied (any client
550/// can send `X-Forwarded-For: <random>`), so each forged value lands in a fresh
551/// bucket and the per-IP limit never bites. We therefore derive the IP only from
552/// a source the operator controls:
553///
554/// * If `trusted_header` is configured (e.g. `Fly-Client-IP`,
555///   `CF-Connecting-IP`), we read the client IP from THAT header only — it is
556///   set by the proxy we run in front and overwrites any client-supplied copy.
557///   We take the LAST value if the header happens to be a comma list (the hop
558///   the trusted proxy appended), which is also the correct read for a
559///   right-most-`X-Forwarded-For` deployment where the operator points
560///   `trusted_header` at `x-forwarded-for`.
561/// * Otherwise we ignore all forwarding headers and use the socket peer
562///   (`ConnectInfo`) — correct for a direct bind with no proxy.
563///
564/// Returns `None` only when neither source yields a parseable IP (the limiter
565/// then fails open for that one request).
566fn client_ip(
567    headers: &HeaderMap,
568    conn: Option<&SocketAddr>,
569    trusted_header: Option<&str>,
570) -> Option<IpAddr> {
571    if let Some(name) = trusted_header {
572        if let Some(raw) = headers.get(name).and_then(|v| v.to_str().ok()) {
573            // Right-most hop is the one the trusted proxy appended; earlier
574            // entries may be client-forged, so never trust the left-most.
575            if let Some(last) = raw.split(',').next_back() {
576                if let Ok(ip) = last.trim().parse::<IpAddr>() {
577                    return Some(ip);
578                }
579            }
580        }
581        // Trusted header absent/unparseable → fall through to the socket peer.
582    }
583    conn.map(|s| s.ip())
584}
585
586/// Rate-limit middleware: 429 on the abuse-prone paths once an IP's bucket is
587/// empty; every other request (and every non-guarded path) passes through. The
588/// peer `SocketAddr` is read from the request extension `ConnectInfo` sets (via
589/// `into_make_service_with_connect_info`), preferring `X-Forwarded-For`.
590async fn rate_limit(
591    State(rl): State<RateLimitState>,
592    req: axum::extract::Request,
593    next: Next,
594) -> Response {
595    let path = req.uri().path().to_string();
596    let method = req.method().clone();
597    if is_rate_limited_path(&path, &method) {
598        let conn = req
599            .extensions()
600            .get::<ConnectInfo<SocketAddr>>()
601            .map(|c| c.0);
602        let ip = client_ip(req.headers(), conn.as_ref(), rl.trusted_header.as_deref());
603        // Deliberately fail OPEN when no client IP is derivable (no trusted
604        // header / no socket peer): there is no per-IP key to enforce, and a
605        // blanket 429 would self-DoS every guarded path (incl. /login). This is
606        // safe precisely because we never key on an attacker-forged XFF — see
607        // `rate_limit_ignores_spoofed_xff_rotation`.
608        if let Some(ip) = ip {
609            if !rl.limiter.check(ip) {
610                warn!(%ip, %path, "rate limit exceeded");
611                return (
612                    StatusCode::TOO_MANY_REQUESTS,
613                    [(header::RETRY_AFTER, "1")],
614                    "rate limit exceeded\n",
615                )
616                    .into_response();
617            }
618        }
619    }
620    next.run(req).await
621}
622
623// ---------------------------------------------------------------------------
624// Cache-Control (viral / CDN vs. private authenticated views)
625// ---------------------------------------------------------------------------
626
627/// Cache-Control middleware. Emits `public, max-age=300` on the cacheable
628/// logged-out surfaces (the `/login` landing without a handle, `/about`,
629/// `/privacy`, `/terms`, and the `/static/*` assets) and `no-store` on the
630/// authenticated app pages, so a CDN /
631/// browser can hold the viral landing while never caching a signed-in user's
632/// private view. Never overrides a handler that already set Cache-Control.
633async fn cache_control(req: axum::extract::Request, next: Next) -> Response {
634    let path = req.uri().path().to_string();
635    // The logged-out landing is only cacheable when it's the bare form — a
636    // `?handle=` GET kicks off OAuth (a redirect), which must not be cached.
637    let is_login_landing = path == "/login"
638        && req.method() == axum::http::Method::GET
639        && !req.uri().query().unwrap_or("").contains("handle=");
640    let public = is_login_landing
641        || path == "/about"
642        || path == "/privacy"
643        || path == "/terms"
644        || path.starts_with("/static/");
645
646    let mut resp = next.run(req).await;
647    if resp.headers().contains_key(header::CACHE_CONTROL) {
648        return resp;
649    }
650    let value = if public {
651        "public, max-age=300"
652    } else {
653        "no-store"
654    };
655    if let Ok(hv) = header::HeaderValue::from_str(value) {
656        resp.headers_mut().insert(header::CACHE_CONTROL, hv);
657    }
658    resp
659}
660
661// ---------------------------------------------------------------------------
662// Health
663// ---------------------------------------------------------------------------
664
665/// Run `/health`'s database probe. **The single path, so a test cannot assert
666/// on a string the handler is free to ignore** — a named constant alone was not
667/// enough: the test read the constant while the handler passed `query_scalar`
668/// whatever it liked, so degrading the real call to `SELECT 1` shipped green.
669async fn health_db_probe(pool: &store::Pool) -> Result<Option<i64>, sqlx::Error> {
670    sqlx::query_scalar::<_, i64>(HEALTH_DB_PROBE_SQL)
671        .fetch_optional(pool)
672        .await
673}
674
675/// The statement `/health` uses to prove the database is readable.
676///
677/// **A named constant so the test can assert on the query that actually runs.**
678/// `the_health_probe_opens_a_real_table` used to `EXPLAIN` a hand-typed copy of
679/// this string, so degrading the real probe to `SELECT 1` — which opens no page
680/// and therefore cannot detect a broken database — left the suite green.
681const HEALTH_DB_PROBE_SQL: &str = "SELECT 1 FROM feeds LIMIT 1";
682
683/// How long `/health` will wait for its database ping before calling it broken.
684///
685/// Under `fly.toml`'s 3 s check timeout, so a hung pool produces a 503 this
686/// handler chose rather than a timeout Fly inferred — the difference between a
687/// log line that says why and one that says nothing.
688const HEALTH_DB_TIMEOUT: Duration = Duration::from_secs(2);
689
690/// Floor for the poll-heartbeat staleness threshold. **Reported, never fatal** —
691/// see the handler for why.
692///
693/// The threshold itself is derived from the configured tick
694/// ([`health_tick_stale_secs`]): hardcoding 15 minutes meant an operator who
695/// raised `FEATHERREADER_POLL_TICK_SECS` above 900 got a permanent `poller:
696/// stale` in the body the deployment docs now tell them to alert on.
697const HEALTH_TICK_STALE_FLOOR_SECS: i64 = 15 * 60;
698
699/// How long without a completed tick before the poller reads as stale: several
700/// tick intervals, floored, so a normally-paced loop never trips it and a
701/// genuinely wedged one always does.
702fn health_tick_stale_secs(tick: Duration) -> i64 {
703    let tick = i64::try_from(tick.as_secs()).unwrap_or(i64::MAX);
704    tick.saturating_mul(5).max(HEALTH_TICK_STALE_FLOOR_SECS)
705}
706
707/// The poll tick this instance is configured for. Read from the same env var
708/// `scheduler.rs` reads, because the scheduler lives in the binary crate and the
709/// handler cannot see its constants.
710fn configured_poll_tick() -> Duration {
711    std::env::var("FEATHERREADER_POLL_TICK_SECS")
712        .ok()
713        .and_then(|v| v.trim().parse::<u64>().ok())
714        .filter(|s| *s > 0)
715        .map_or(DEFAULT_POLL_TICK_SECS, Duration::from_secs)
716}
717
718/// Mirrors `scheduler::DEFAULT_POLL_TICK`, which lives in the BINARY crate and
719/// so cannot be imported here. Duplicated deliberately and named, rather than
720/// left as a bare `60` inside the parse chain, so the drift is at least visible
721/// if the scheduler's value ever moves.
722const DEFAULT_POLL_TICK_SECS: Duration = Duration::from_secs(60);
723
724/// Grace period after boot before a poller that has never ticked is called
725/// `stale` rather than `not-yet-ticked`.
726///
727/// Without this the two are indistinguishable forever, which matters precisely
728/// in the case the startup delays were added for: in a crash loop with 30 s+ boot
729/// cycles the poller never reaches its first tick, so `/health` reported the
730/// benign `not-yet-ticked` on every single probe and the heartbeat could not
731/// detect the failure mode it exists for. `run_poller` returning early — a failed
732/// HTTP client build — has the same shape and was equally invisible.
733///
734/// Sized off the poller's own startup delay plus its tick, with slack.
735const HEALTH_FIRST_TICK_GRACE_SECS: i64 = 5 * 60;
736
737/// `GET /health` — does this process still work, and what are its loops doing?
738///
739/// This used to return a constant string, touching no database, no pool and no
740/// scheduler state — while being the ONLY automated signal in `fly.toml`, whose
741/// sole other failure detector is a child process exiting. It proved the HTTP
742/// listener was up and nothing else.
743///
744/// **What can fail the check: the database, and only the database.** A process
745/// that cannot reach its store serves nothing, so a restart is the right
746/// response and this returns 503. The probe is a read (`SELECT 1`), which in WAL
747/// mode is not blocked by any writer — so the retention sweep, the poller and a
748/// login burst cannot make this flap. That property is the reason it is a read
749/// and not, say, a write canary.
750///
751/// **What is reported but never fails the check: everything else.** A stale poll
752/// heartbeat, a watermark pause, a missing OAuth runtime — all real problems,
753/// and none of them a reason to stop serving.
754///
755/// That last clause is the whole justification, and it is NOT the one this
756/// comment used to give. It said "Fly restarts on a failed check", which is
757/// false — verified against Fly's own docs, which state it three times: *"your
758/// Machines won't automatically restart or stop due to failing their health
759/// checks"*. A failing `[[http_service.checks]]` check makes Fly Proxy stop
760/// ROUTING to the Machine. Nothing restarts it. That capability existed on Apps
761/// V1 (`restart_limit`) and has no successor on Machines.
762///
763/// The corrected model makes the conclusion stronger, not weaker. With one
764/// Machine there is no healthy peer to shift traffic to, so a 503 here is not a
765/// failover — it is a total outage that lasts exactly as long as the condition,
766/// and it also fails a `fly deploy` (rolling strategy, no auto-rollback). So the
767/// question the status code answers is not "would a restart fix this" but **"can
768/// this process still serve a useful request at all"**. A stale poller can. A
769/// database it cannot read cannot.
770///
771/// Re-registration is automatic: the proxy keeps probing and routes again the
772/// moment the check passes. That is what makes a 503 recoverable without
773/// intervention — not a restart, which never comes.
774///
775/// The body is machine facts only — no user counts, no DIDs, no feed URLs — so
776/// it is publishable on the same terms as `/stats`. It is also the non-session
777/// diagnostic for an OAuth outage: when nobody can log in, `/admin/metrics`
778/// (which needs a live admin session) is exactly as unreachable as the thing it
779/// would diagnose, while this is reachable with `curl`.
780async fn health(State(state): State<AppState>) -> Response {
781    let now = chrono::Utc::now().timestamp();
782    let rh = &state.runtime_health;
783
784    use crate::runtime_health::DbProbe;
785    let db = match rh.begin_db_probe() {
786        // A probe is already in flight; report its predecessor rather than
787        // starting a second one. See `RuntimeHealth::begin_db_probe`.
788        Err(borrowed) => borrowed,
789        Ok(probe) => {
790            // **Spawned, so the probe cannot be cancelled by the caller.**
791            //
792            // Axum drops the handler future when a client disconnects. With the
793            // probe inline, that dropped it mid-flight and released the claim
794            // WITHOUT recording a verdict — which let an unauthenticated caller
795            // manufacture the no-verdict state on demand and freeze what every
796            // other caller, Fly's check included, reads. Running it detached
797            // means the verdict is always recorded and the claim is always
798            // released after it.
799            let pool = state.db.clone();
800            let task = tokio::spawn(async move {
801                // **`SELECT 1` was not a database probe.** It compiles to
802                // `Init/Integer/ResultRow/Halt` — there is no `OpenRead`, so it
803                // never touches a b-tree, never reads a page, and never consults
804                // the file. Against a corrupted database it returns success
805                // while every real query returns SQLITE_CORRUPT. Reading one row
806                // from a real table costs the same and actually proves what the
807                // check claims. `LIMIT 1` keeps it to a single page; an empty
808                // table still opens the b-tree root, which is the part that
809                // matters.
810                let verdict =
811                    match tokio::time::timeout(HEALTH_DB_TIMEOUT, health_db_probe(&pool)).await {
812                        Ok(Ok(_)) => DbProbe::Ok,
813                        // Coarse, not the raw error. An unauthenticated caller
814                        // learning exactly which failure it hit is an
815                        // attack-progress oracle; the detail belongs in the log,
816                        // which gets it here.
817                        Ok(Err(err)) => {
818                            warn!(%err, "health: database probe failed");
819                            DbProbe::Failed("unavailable".to_string())
820                        }
821                        Err(_) => {
822                            warn!(
823                                timeout_s = HEALTH_DB_TIMEOUT.as_secs(),
824                                "health: database probe timed out (pool exhausted?)"
825                            );
826                            DbProbe::Failed("timeout".to_string())
827                        }
828                    };
829                probe.record(verdict.clone());
830                verdict
831            });
832            // A panicking task drops the guard, which releases the claim without
833            // a verdict — the only remaining path to that state, and not one a
834            // caller can drive.
835            task.await.unwrap_or(DbProbe::Unknown)
836        }
837    };
838
839    let uptime = rh.uptime_secs(now);
840    let poller = if !rh.schedulers_enabled() {
841        // Not a fault. Dev runs and the seam tests disable the loops on purpose,
842        // and reporting that as "stale" would be a false alarm on every one.
843        "disabled".to_string()
844    } else {
845        match rh.secs_since_poll_tick(now) {
846            // "Never ticked" is benign right after boot and alarming well after
847            // it — so it is read against UPTIME, not left permanently benign.
848            None => match uptime {
849                Some(up) if up > HEALTH_FIRST_TICK_GRACE_SECS => {
850                    format!("stale never-ticked {up}s")
851                }
852                _ => "not-yet-ticked".to_string(),
853            },
854            Some(secs) if secs > health_tick_stale_secs(configured_poll_tick()) => {
855                format!("stale {secs}s")
856            }
857            Some(secs) => format!("ok {secs}s"),
858        }
859    };
860
861    // **Only a MEASURED failure fails the check.**
862    //
863    // `Unknown` means no probe has completed — a concurrent request arrived
864    // before the first one finished, or a previous owner was cancelled before
865    // recording. It is reported and returns 200, because an unmeasured database
866    // is not evidence of a broken one, and this endpoint is reachable by
867    // unauthenticated callers who can manufacture that state. Treating it as a
868    // failure handed them a lever on the only signal the platform acts on.
869    let mut body = String::new();
870    let status = match &db {
871        DbProbe::Ok => {
872            body.push_str(&format!("ok featherreader/{VERSION}\n"));
873            body.push_str("db: ok\n");
874            StatusCode::OK
875        }
876        // **Not `ok`.** The first token is the state, and this one is neither
877        // healthy nor failed. It used to print a line byte-identical to the
878        // healthy branch, which mattered because `fly.toml` tells operators to
879        // alert on the BODY for everything the status code deliberately ignores
880        // — so a monitor keying on `^ok` read green in exactly the state this
881        // enum exists to make visible.
882        DbProbe::Unknown => {
883            body.push_str(&format!("unknown featherreader/{VERSION}\n"));
884            body.push_str("db: unknown (no probe has completed yet)\n");
885            StatusCode::OK
886        }
887        DbProbe::Failed(why) => {
888            body.push_str(&format!("FAIL featherreader/{VERSION}\n"));
889            body.push_str(&format!("db: {why}\n"));
890            StatusCode::SERVICE_UNAVAILABLE
891        }
892    };
893    // Uptime answers the first question anyone asks about a container under a
894    // supervisor that tears the machine down whenever a child exits: is this
895    // thing restarting? Nothing else on any surface could tell you.
896    body.push_str(&format!(
897        "uptime: {}\n",
898        match uptime {
899            Some(secs) => format!("{secs}s"),
900            None => "unknown".to_string(),
901        }
902    ));
903    body.push_str(&format!("poller: {poller}\n"));
904    body.push_str(&format!(
905        "polling-paused: {}\n",
906        if rh.watermark_paused() { "yes" } else { "no" }
907    ));
908    // Deliberately NOT the measured database size. `/health` is the one path
909    // exempted from the Caddy origin lock, so it answers direct hits to the Fly
910    // IP that never passed Cloudflare — which caps what belongs here at the
911    // class of facts `/stats` already publishes to anyone. "Polling is paused"
912    // is that; the exact byte count is a precise internal number that adds
913    // nothing an operator cannot get from `/stats` or the logs.
914    body.push_str(&format!(
915        "backend: {}\n",
916        state.config.repo_backend.as_str()
917    ));
918    body.push_str(&format!(
919        "oauth-runtime: {}\n",
920        if state.oauth.is_some() {
921            "built"
922        } else {
923            "absent"
924        }
925    ));
926
927    // Never cached: a stale health response is worse than none, and Cloudflare
928    // sits in front of this.
929    let mut resp = (status, body).into_response();
930    if let Ok(hv) = header::HeaderValue::from_str("no-store") {
931        resp.headers_mut().insert(header::CACHE_CONTROL, hv);
932    }
933    resp
934}
935
936/// `GET /about` — the public-experiment page: the full disclaimer (experimental,
937/// no SLA, may pause anytime), the OSS / self-host pitch, and the tip link.
938/// Readable whether or not a session exists.
939///
940/// Optionally carries one quiet line about network adoption
941/// (`design/NETWORK-SPEC.md` §4.4). With `FEATHERREADER_SHOW_ADOPTION` off — the
942/// default — the handler issues **zero** queries and the page is byte-identical
943/// to what it was before the probe existed.
944async fn about(State(state): State<AppState>) -> Response {
945    let adoption = if state.config.show_adoption {
946        adoption_line(&state).await
947    } else {
948        None
949    };
950    render(&AboutTemplate {
951        version: VERSION,
952        repo_url: REPO_URL,
953        kofi_url: KOFI_URL,
954        adoption,
955    })
956}
957
958/// `POST /saved/:rkey/delete` — remove a saved record that has no local entry.
959///
960/// The normal star toggle is keyed on an entry id, which a PDS-only saved row
961/// does not have. This deletes the record straight from the repo by its rkey,
962/// and then clears any LOCAL star for the same article.
963///
964/// That second step is not belt-and-braces. "Has no local entry" is how the
965/// starred view classifies a record, and it decides that through `sub_ref` — so
966/// an article that really is cached, and really is starred, lands here whenever
967/// the reader has unsubscribed from its feed. Deleting only the record left
968/// `entry_state.starred = 1` behind: invisible, because the starred list is
969/// `sub_ref`-scoped too, until a resubscribe brought the star back with nothing
970/// in the PDS backing it. A reader who clicks "remove" gets it removed from both
971/// places it lives.
972async fn unsave_record(
973    State(state): State<AppState>,
974    headers: HeaderMap,
975    Path(rkey): Path<String>,
976) -> Response {
977    let Some(did) = current_did(&state, &headers).await else {
978        return (StatusCode::UNAUTHORIZED, "sign in first\n").into_response();
979    };
980
981    // Read the record's identity BEFORE deleting it — afterwards there is
982    // nothing left to learn it from. Best-effort: a failure here must not block
983    // the deletion the reader actually asked for, so it degrades to the old
984    // behaviour (record gone, local star possibly stale) and says so.
985    let identity = match state.repo().list_saved(&did).await {
986        Ok(records) => records
987            .into_iter()
988            .find(|(k, _)| *k == rkey)
989            .map(|(_, rec)| (rec.url, rec.entry_id)),
990        Err(err) => {
991            warn!(%err, %did, %rkey, "could not read the saved record before deleting it; \
992                                      a local star for the same article may survive");
993            None
994        }
995    };
996
997    match state.repo().remove_saved(&did, &rkey).await {
998        Ok(()) => info!(%did, %rkey, "removed a saved record with no cached entry"),
999        Err(err) => {
1000            warn!(%err, %did, %rkey, "could not remove the saved record");
1001            return (StatusCode::BAD_GATEWAY, "could not remove that item\n").into_response();
1002        }
1003    }
1004
1005    // Deliberately AFTER the delete: the PDS is the source of truth for what was
1006    // saved, so clearing the local star before knowing the record is gone would
1007    // be the desync in the other direction.
1008    if let Some((url, guid)) = identity {
1009        match store::clear_star_by_identity(&state.db, &did, Some(&url), guid.as_deref()).await {
1010            Ok(0) => {}
1011            Ok(n) => {
1012                info!(%did, %rkey, cleared = n, "cleared the local star for an unsaved record")
1013            }
1014            Err(err) => warn!(%err, %did, %rkey, "could not clear the local star after unsaving"),
1015        }
1016    }
1017    // htmx swaps the row out; a plain form post goes back to the starred list.
1018    if is_htmx(&headers) {
1019        return (StatusCode::OK, "").into_response();
1020    }
1021    Redirect::to("/?view=starred").into_response()
1022}
1023
1024/// What the poller is doing, as one word for `/stats`.
1025///
1026/// **Parity with `/health` is the point.** `polling_paused` alone reported
1027/// "running" for three different states including the two where nothing polls,
1028/// on the page added to answer exactly that. The first attempt at fixing it
1029/// added `off` and `starting` and claimed parity — but left out `stale`, so a
1030/// poll loop that ticked once at boot and then WEDGED still read as running.
1031/// That is the wedged-loop case `/health`'s heartbeat exists for, and the
1032/// original finding's exact shape surviving its own fix.
1033///
1034/// Shares the staleness threshold with `/health` rather than picking its own, so
1035/// the two pages cannot disagree about what "stale" means.
1036fn fetching_state(rh: &crate::runtime_health::RuntimeHealth, now_unix: i64) -> &'static str {
1037    if !rh.schedulers_enabled() {
1038        return "off";
1039    }
1040    // Checked before the pause: a wedged poller cannot clear a pause either, so
1041    // reporting "paused" would name the symptom and hide the cause.
1042    match rh.secs_since_poll_tick(now_unix) {
1043        None => {
1044            // Never ticked. Benign at boot, a dead loop long after — read
1045            // against uptime, exactly as `/health` does.
1046            match rh.uptime_secs(now_unix) {
1047                Some(up) if up > HEALTH_FIRST_TICK_GRACE_SECS => "stale",
1048                _ => "starting",
1049            }
1050        }
1051        Some(secs) if secs > health_tick_stale_secs(configured_poll_tick()) => "stale",
1052        _ if rh.watermark_paused() => "paused",
1053        _ => "running",
1054    }
1055}
1056
1057/// `GET /stats` — public poll health.
1058async fn stats(State(state): State<AppState>) -> Response {
1059    let now = chrono::Utc::now();
1060    let health = match store::poll_health(
1061        &state.db,
1062        &now.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
1063        &(now - chrono::Duration::hours(1)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
1064    )
1065    .await
1066    {
1067        Ok(health) => health,
1068        Err(err) => {
1069            warn!(%err, "could not compute poll health");
1070            return (StatusCode::INTERNAL_SERVER_ERROR, "stats unavailable\n").into_response();
1071        }
1072    };
1073
1074    // Percentage of a zero-feed instance is 100, not a divide-by-zero: a fresh
1075    // instance is not behind on anything.
1076    let polled_pct = if health.feeds_tracked == 0 {
1077        100
1078    } else {
1079        health.polled_last_hour * 100 / health.feeds_tracked
1080    };
1081
1082    render(&StatsTemplate {
1083        version: VERSION,
1084        repo_url: REPO_URL,
1085        kofi_url: KOFI_URL,
1086        feeds_tracked: health.feeds_tracked,
1087        polled_last_hour: health.polled_last_hour,
1088        polled_pct,
1089        overdue: health.overdue,
1090        last_poll: humanise_ago(health.last_poll_secs_ago),
1091        oldest_poll: if health.never_polled > 0 {
1092            "never".to_string()
1093        } else {
1094            humanise_ago(health.oldest_poll_secs_ago)
1095        },
1096        never_polled: health.never_polled,
1097        poll_interval_mins: state.config.poll_interval.as_secs() as i64 / 60,
1098        // **The two states that actually stop feeds updating.**
1099        //
1100        // Neither was visible anywhere. `overdue` and `polled_last_hour` move in
1101        // both and distinguish neither — and `overdue` moves the WRONG WAY for
1102        // backoff, since backoff is applied by pushing `next_poll` forward, so a
1103        // feed failing every fetch drops out of the backlog and makes the page
1104        // read healthier. Both of these are machine facts with no per-feed
1105        // detail, so they sit inside the page's stated contract.
1106        in_backoff: health.in_backoff,
1107        badly_broken: health.badly_broken,
1108        failure_kinds: health.failure_kinds,
1109        fetching: fetching_state(&state.runtime_health, now.timestamp()),
1110    })
1111}
1112
1113/// "3h 11m ago", or "never" when there has been no poll at all.
1114///
1115/// `None` must not render as `0` — on a fresh instance that would read as
1116/// "polled just now", which is the opposite of the truth.
1117fn humanise_ago(secs: Option<i64>) -> String {
1118    let Some(secs) = secs else {
1119        return "never".to_string();
1120    };
1121    match secs {
1122        s if s < 60 => format!("{s}s ago"),
1123        s if s < 3600 => format!("{}m ago", s / 60),
1124        s => format!("{}h {}m ago", s / 3600, (s % 3600) / 60),
1125    }
1126}
1127
1128/// The `/about` adoption line's data, or `None` (no successful probe yet, an
1129/// observation of zero, or a store failure).
1130///
1131/// A read failure degrades to `None` plus a `warn!` rather than propagating: the
1132/// probe is never allowed to affect the reader, and that rule applies at the
1133/// display end too — a locked or corrupt DB costs the About page one log line,
1134/// not a 500.
1135async fn adoption_line(state: &AppState) -> Option<AdoptionLine> {
1136    match store::latest_network_stat(&state.db, store::ADOPTION_STAT_KEY).await {
1137        // A legitimate zero renders nothing rather than a sad "0 accounts".
1138        Ok(Some(stat)) if stat.value > 0 => Some(AdoptionLine {
1139            repos: stat.value,
1140            truncated: stat.truncated,
1141            observed_on: stat
1142                .observed_at
1143                .split('T')
1144                .next()
1145                .unwrap_or_default()
1146                .to_string(),
1147        }),
1148        Ok(_) => None,
1149        Err(err) => {
1150            warn!(%err, "about: adoption stat read failed; omitting the line");
1151            None
1152        }
1153    }
1154}
1155
1156/// `GET /privacy` — the plain-language privacy page: no account/tracking, data
1157/// lives in the user's PDS, what the server caches, and the session-token
1158/// handling. A static render; readable whether or not a session exists.
1159async fn privacy() -> Response {
1160    render(&PrivacyTemplate {
1161        version: VERSION,
1162        repo_url: REPO_URL,
1163        kofi_url: KOFI_URL,
1164    })
1165}
1166
1167/// `GET /terms` — the terms of use: the experimental / as-is disclaimer,
1168/// acceptable use, the AGPL/self-host note, and the liability limitation. A
1169/// static render; readable whether or not a session exists.
1170async fn terms() -> Response {
1171    render(&TermsTemplate {
1172        version: VERSION,
1173        repo_url: REPO_URL,
1174        kofi_url: KOFI_URL,
1175    })
1176}
1177
1178// ---------------------------------------------------------------------------
1179// View models
1180// ---------------------------------------------------------------------------
1181
1182/// A feed as shown in the sidebar (title + its unread count + a stable scope key
1183/// and the PDS subscription rkey for management actions).
1184struct FeedView {
1185    /// PDS subscription rkey — addresses the record for rename/unsubscribe.
1186    rkey: String,
1187    /// Canonical feed URL — the sidebar filter key (`?feed=<url>`).
1188    url: String,
1189    title: String,
1190    unread: i64,
1191    /// Whether this feed is the currently-selected scope.
1192    selected: bool,
1193    /// The feed's current folder `at://` URI (from its subscription record), or
1194    /// `None` if un-foldered. Drives the pre-selected `<option>` in the manage
1195    /// rename row so an untouched folder dropdown does not silently un-folder the
1196    /// feed on save.
1197    folder: Option<String>,
1198}
1199
1200/// A folder grouping in the sidebar, sourced from the PDS `folder` records.
1201struct FolderView {
1202    /// PDS folder rkey — addresses the record for rename/delete.
1203    rkey: String,
1204    /// The folder's `at://` URI — the sidebar filter key (`?folder=<uri>`).
1205    uri: String,
1206    name: String,
1207    feeds: Vec<FeedView>,
1208    /// Whether this folder is the currently-selected scope.
1209    selected: bool,
1210}
1211
1212/// One entry as shown in the article list / after an htmx swap.
1213struct EntryRow {
1214    id: i64,
1215    title: String,
1216    feed_title: String,
1217    published: String,
1218    read: bool,
1219    starred: bool,
1220    /// The reader link href, already carrying the scope/view query so opening an
1221    /// entry and paging back stays within the list it came from.
1222    link: SafeLink,
1223    /// Whether the article itself is in this instance's cache.
1224    ///
1225    /// `false` for a saved record that exists in the reader's PDS but whose
1226    /// entry was never cached here — starred in another atproto reader, or
1227    /// starred here and since evicted. There is no local row, so the row has no
1228    /// usable `id`: it links straight out to the article and carries no
1229    /// mark-read control, because there is nothing local to mark.
1230    cached: bool,
1231    /// The PDS record key, for un-saving a row that has no local entry.
1232    rkey: String,
1233}
1234
1235/// A folder as an option in the "move feed to folder" select.
1236struct FolderOption {
1237    uri: String,
1238    name: String,
1239}
1240
1241/// The shared navigation "rail" model: the same DOM element is the
1242/// mobile drawer and the desktop sidebar, so every chrome page (list / reader /
1243/// manage) renders it from this one struct. Feed management lives on `/manage`,
1244/// not here — the rail is navigation only.
1245struct Nav {
1246    /// `@handle` for the identity chip (falls back to the DID's tail).
1247    handle: String,
1248    /// Two-letter avatar initials for the identity chip.
1249    avatar: String,
1250    /// The active filter: `"unread" | "all" | "starred"` (drives `aria-current`).
1251    view: String,
1252    /// The scope query suffix (`feed=…` / `folder=…`) carried onto filter links,
1253    /// empty for the unscoped "everything" views.
1254    scope_qs: String,
1255    /// Folders (each with its feeds) then un-foldered feeds, for the rail lists.
1256    /// Per-feed `selected` flags drive the rail's feed `aria-current`.
1257    folders: Vec<FolderView>,
1258    loose_feeds: Vec<FeedView>,
1259    /// Whether the "Manage feeds" rail tool is the current page.
1260    manage_active: bool,
1261}
1262
1263/// The reader index (`GET /`).
1264#[derive(Template)]
1265#[template(path = "index.html")]
1266struct IndexTemplate {
1267    version: &'static str,
1268    repo_url: &'static str,
1269    kofi_url: &'static str,
1270    flash: String,
1271    /// Shown as `role="alert"` when the subscription list is the cached one
1272    /// because the PDS listing failed; empty otherwise.
1273    alert: String,
1274    /// The shared rail (drawer + desktop sidebar) navigation model.
1275    nav: Nav,
1276    /// The article list for the selected scope + view.
1277    entries: Vec<EntryRow>,
1278    /// The list heading (the selected view/feed/folder name).
1279    heading: String,
1280    /// Whether a feed scope is active (enables per-feed mark-all-read).
1281    feed_scope: Option<String>,
1282    /// Total CACHED entries in this scope + view across ALL pages. The count used
1283    /// to be `entries.len()`, which was the same number only because the list was
1284    /// unpaged — the thing this change exists to stop.
1285    ///
1286    /// The pager is derived from this, so it must not include the uncached PDS
1287    /// rows below: they are appended to the last page rather than paged, and
1288    /// counting them here advertised a page the clamp could never reach.
1289    total: i64,
1290    /// How many of `total` are PDS saved records the cache cannot show.
1291    ///
1292    /// A subset of `total`, not an addition to it — the heading says "N entries
1293    /// (M saved elsewhere)". An earlier version rendered "N entries, plus M",
1294    /// which double counted once `total` started including them, against an M
1295    /// that had become page-local in the same commit while the template stayed
1296    /// put.
1297    uncached_total: i64,
1298    /// 1-based current page.
1299    page: i64,
1300    /// Total pages, at least 1 (an empty list is page 1 of 1).
1301    page_count: i64,
1302    /// Link to the previous (newer) page, or `None` on the first.
1303    prev_href: Option<String>,
1304    /// Link to the next (older) page, or `None` on the last.
1305    next_href: Option<String>,
1306}
1307
1308/// The feed-management page (`GET /manage`) — subscribe / your-feeds / OPML.
1309#[derive(Template)]
1310#[template(path = "manage.html")]
1311struct ManageTemplate {
1312    version: &'static str,
1313    repo_url: &'static str,
1314    kofi_url: &'static str,
1315    flash: String,
1316    /// See [`IndexTemplate::alert`].
1317    alert: String,
1318    nav: Nav,
1319    /// All folders as move-targets for the subscribe folder select.
1320    folder_options: Vec<FolderOption>,
1321    /// Folders (each with feeds) + loose feeds, for the "Your feeds" list.
1322    folders: Vec<FolderView>,
1323    loose_feeds: Vec<FeedView>,
1324}
1325
1326/// The optional one-line adoption fact at the bottom of `/about`
1327/// (`design/NETWORK-SPEC.md` §4.4). `None` whenever the display flag is off, no
1328/// probe has succeeded yet, or the read failed — the line then simply does not
1329/// render.
1330struct AdoptionLine {
1331    /// Repos a relay has indexed as holding the subscription collection.
1332    repos: i64,
1333    /// The probe hit its page cap, so the copy must say "at least".
1334    truncated: bool,
1335    /// Observation date, `YYYY-MM-DD` (UTC), sliced from the stored RFC3339 stamp.
1336    observed_on: String,
1337}
1338
1339/// The public-experiment `/about` page — disclaimer + OSS pitch + tip link, plus
1340/// the optional adoption line.
1341#[derive(Template)]
1342#[template(path = "about.html")]
1343struct AboutTemplate {
1344    version: &'static str,
1345    repo_url: &'static str,
1346    kofi_url: &'static str,
1347    adoption: Option<AdoptionLine>,
1348}
1349
1350/// The public `/stats` page — is the poller keeping up?
1351///
1352/// Aggregate only, deliberately. It is published to anyone, so it carries no
1353/// user counts and no per-feed detail: a reader does not need to know how many
1354/// people use an instance or which feeds are failing. What it does answer is the
1355/// question that decides whether an instance can take more readers — whether the
1356/// poller is servicing the feeds it already has.
1357///
1358/// The counts below are aggregate machine facts, which is why they fit that
1359/// contract: "12 feeds are in backoff" names no feed and no reader, while
1360/// answering the question the page was previously unable to answer at all.
1361#[derive(Template)]
1362#[template(path = "stats.html")]
1363struct StatsTemplate {
1364    version: &'static str,
1365    repo_url: &'static str,
1366    kofi_url: &'static str,
1367    feeds_tracked: i64,
1368    polled_last_hour: i64,
1369    polled_pct: i64,
1370    overdue: i64,
1371    last_poll: String,
1372    oldest_poll: String,
1373    never_polled: i64,
1374    poll_interval_mins: i64,
1375    /// Feeds in error backoff. Invisible before, and excluded from `overdue`.
1376    in_backoff: i64,
1377    /// Of those, the ones retried hours apart rather than minutes. **Not
1378    /// "effectively dead"** — see `store::BADLY_BROKEN_ERRORS`; they recover on
1379    /// their next successful poll, and most of this instance's did.
1380    badly_broken: i64,
1381    /// Failing feeds by cause, descending — counts only, never which feed.
1382    failure_kinds: Vec<(String, i64)>,
1383    /// What the poller is actually doing: `running`, `paused` (at the size
1384    /// watermark), `starting` (no tick completed yet) or `off` (schedulers
1385    /// disabled). Three of those four used to render as "running".
1386    fetching: &'static str,
1387}
1388
1389/// The public `/privacy` page — what the server holds vs. what lives in the
1390/// user's PDS. Carries the same `repo_url`/`kofi_url`/`version` the shared
1391/// footer include needs.
1392#[derive(Template)]
1393#[template(path = "privacy.html")]
1394struct PrivacyTemplate {
1395    version: &'static str,
1396    repo_url: &'static str,
1397    kofi_url: &'static str,
1398}
1399
1400/// The public `/terms` page — the as-is / no-warranty terms of use. Carries the
1401/// same fields the shared footer include needs.
1402#[derive(Template)]
1403#[template(path = "terms.html")]
1404struct TermsTemplate {
1405    version: &'static str,
1406    repo_url: &'static str,
1407    kofi_url: &'static str,
1408}
1409
1410/// The signed-out landing page (`GET /` with no session) — the public front
1411/// door at feather-reader.com. A static render, no session required.
1412#[derive(Template)]
1413#[template(path = "landing.html")]
1414struct LandingTemplate {
1415    version: &'static str,
1416    repo_url: &'static str,
1417    crates_url: &'static str,
1418    kofi_url: &'static str,
1419}
1420
1421/// The single-entry reader view (`GET /entries/:id`).
1422#[derive(Template)]
1423#[template(path = "entry.html")]
1424struct EntryTemplate {
1425    version: &'static str,
1426    repo_url: &'static str,
1427    kofi_url: &'static str,
1428    nav: Nav,
1429    id: i64,
1430    title: String,
1431    feed_title: String,
1432    author: Option<String>,
1433    published: String,
1434    /// The entry's own link, for `entry.html`'s two `href`s.
1435    ///
1436    /// `Option<SafeLink>`, not `Option<String>`: the column it comes from holds
1437    /// a remote feed's `<link>`. Ingest scheme-checks it, but that guard is a
1438    /// long way from the `href` and holds only while every future writer to
1439    /// `entries.url` remembers to go through `feed.rs` — the same procedural
1440    /// defence that, on the saved-record row, turned out to be deletable with
1441    /// all 679 tests still green. `None` is the refusal: the template's
1442    /// no-URL branch already renders a disabled open-original button.
1443    url: Option<SafeLink>,
1444    content_html: Option<String>,
1445    read: bool,
1446    starred: bool,
1447    /// The query string to carry the reading context back to the list.
1448    back_qs: String,
1449    /// Prev/next entry ids within the current list, for keyboard/paging nav.
1450    prev_id: Option<i64>,
1451    next_id: Option<i64>,
1452    /// Rendered inline (not an out-of-band swap fragment): always `false` here.
1453    oob: bool,
1454}
1455
1456/// The htmx swap fragment for a single entry row (`entry_row.html`).
1457#[derive(Template)]
1458#[template(path = "entry_row.html")]
1459struct EntryRowTemplate {
1460    e: EntryRow,
1461}
1462
1463/// The reader's action-bar fragment (`entry_actionbar.html`) returned as an
1464/// out-of-band swap after a mark-read / star toggle FROM THE READER, so the
1465/// button's hidden value + `aria-pressed` update in place (the reader `<li>`
1466/// isn't in the DOM to swap, unlike the list view's `entry_row.html`).
1467#[derive(Template)]
1468#[template(path = "entry_actionbar.html")]
1469struct EntryActionBarTemplate {
1470    id: i64,
1471    read: bool,
1472    starred: bool,
1473    /// Emit the `hx-swap-oob` attribute: `true` for the handler's OOB response.
1474    oob: bool,
1475}
1476
1477/// The login stub (`GET /login`).
1478#[derive(Template)]
1479#[template(path = "login.html")]
1480struct LoginTemplate {
1481    repo_url: &'static str,
1482    error: String,
1483    /// A neutral/success banner (e.g. the post-delete "signed out" confirmation),
1484    /// distinct from `error`. Empty renders nothing.
1485    flash: String,
1486}
1487
1488/// The closed-beta invite-redeem page (`GET /beta/redeem`).
1489#[derive(Template)]
1490#[template(path = "beta_redeem.html")]
1491struct BetaRedeemTemplate {
1492    repo_url: &'static str,
1493    error: String,
1494    /// When true the seat cap is full: hide the form and show the "capacity
1495    /// full — try self-hosting" message instead.
1496    capacity_full: bool,
1497}
1498
1499// ---------------------------------------------------------------------------
1500// Rendering + error helpers
1501// ---------------------------------------------------------------------------
1502
1503/// Render an askama template into an HTML response, mapping a render failure to
1504/// a `500` rather than panicking (no `unwrap` in the request path).
1505fn render<T: Template>(tmpl: &T) -> Response {
1506    match tmpl.render() {
1507        Ok(body) => Html(body).into_response(),
1508        Err(err) => {
1509            warn!(%err, "template render failed");
1510            (StatusCode::INTERNAL_SERVER_ERROR, "template render error").into_response()
1511        }
1512    }
1513}
1514
1515/// A minimal web error type so handlers can `?`-propagate `anyhow` failures and
1516/// still return an `impl IntoResponse`. Renders as a `500` with a short message
1517/// by default; a handler may override the status (e.g. `413` for an over-cap
1518/// upload) via [`WebError::with_status`].
1519struct WebError {
1520    err: anyhow::Error,
1521    status: StatusCode,
1522}
1523
1524impl<E: Into<anyhow::Error>> From<E> for WebError {
1525    fn from(err: E) -> Self {
1526        WebError {
1527            err: err.into(),
1528            status: StatusCode::INTERNAL_SERVER_ERROR,
1529        }
1530    }
1531}
1532
1533impl WebError {
1534    /// Attach an explicit HTTP status to render instead of the default `500`.
1535    fn with_status(err: impl Into<anyhow::Error>, status: StatusCode) -> Self {
1536        WebError {
1537            err: err.into(),
1538            status,
1539        }
1540    }
1541}
1542
1543impl IntoResponse for WebError {
1544    fn into_response(self) -> Response {
1545        warn!(error = %self.err, status = %self.status, "request failed");
1546        let body = if self.status == StatusCode::INTERNAL_SERVER_ERROR {
1547            "internal error"
1548        } else {
1549            self.status.canonical_reason().unwrap_or("error")
1550        };
1551        (self.status, body).into_response()
1552    }
1553}
1554
1555/// Map an axum [`MultipartError`] to a [`WebError`] that preserves the error's
1556/// own HTTP status. When a request exceeds the route's `DefaultBodyLimit` the
1557/// multipart extractor reports `413 Payload Too Large`; a malformed body reports
1558/// `400`. Either way this avoids collapsing the failure into a generic `500`.
1559fn multipart_response(err: axum::extract::multipart::MultipartError) -> WebError {
1560    let status = err.status();
1561    WebError::with_status(err, status)
1562}
1563
1564/// A short, human display of a feed/site title for the sidebar/list, falling
1565/// back to the host of a URL and finally to the raw string.
1566fn display_title(title: Option<&str>, url: &str) -> String {
1567    if let Some(t) = title {
1568        let t = t.trim();
1569        if !t.is_empty() {
1570            return t.to_string();
1571        }
1572    }
1573    url::Url::parse(url)
1574        .ok()
1575        .and_then(|u| u.host_str().map(str::to_string))
1576        .unwrap_or_else(|| url.to_string())
1577}
1578
1579/// A display `@handle` for the identity chip: the stored handle if present,
1580/// else the tail of the DID so the chip is never empty.
1581fn display_handle(handle: Option<&str>, did: &str) -> String {
1582    match handle {
1583        Some(h) if !h.trim().is_empty() => format!("@{}", h.trim().trim_start_matches('@')),
1584        _ => did.rsplit(':').next().unwrap_or(did).to_string(),
1585    }
1586}
1587
1588/// Two-letter, lowercase avatar initials from a handle/DID.
1589fn avatar_initials(handle: Option<&str>, did: &str) -> String {
1590    let source = handle
1591        .map(|h| h.trim().trim_start_matches('@'))
1592        .filter(|h| !h.is_empty())
1593        .unwrap_or_else(|| did.rsplit(':').next().unwrap_or(did));
1594    let letters: String = source
1595        .chars()
1596        .filter(|c| c.is_alphanumeric())
1597        .take(2)
1598        .collect::<String>()
1599        .to_lowercase();
1600    if letters.is_empty() {
1601        "fr".to_string()
1602    } else {
1603        letters
1604    }
1605}
1606
1607/// Trim a stored RFC3339 timestamp down to the `YYYY-MM-DD` date for calm,
1608/// low-noise display. Falls back to the raw string if it doesn't look like one.
1609fn display_date(published: Option<&str>) -> String {
1610    // CHARACTERS, not bytes. `p[..10]` panics when byte 10 lands inside a
1611    // multi-byte character, and every caller used to pass a timestamp the feed
1612    // parser had produced. The saved-record path passes `createdAt` straight off
1613    // a PDS record, which the lexicon types as a bare string with no validation
1614    // — written by whatever atproto client the reader used. A `createdAt` of
1615    // "日本語日本語日本" took down the whole starred view, and there is no
1616    // catch-panic layer in the stack, so the page stayed down until the record
1617    // was removed from the very view that would not render.
1618    match published {
1619        Some(p) => p.chars().take(10).collect(),
1620        None => String::new(),
1621    }
1622}
1623
1624/// Percent-encode a value for use in a query string (RFC 3986 unreserved kept).
1625/// Small and dependency-free — the `url` crate's form-encoding isn't exposed for
1626/// a bare value, and this keeps the scope-preserving links honest.
1627fn qenc(s: &str) -> String {
1628    let mut out = String::with_capacity(s.len() * 3);
1629    for b in s.bytes() {
1630        match b {
1631            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
1632                out.push(b as char)
1633            }
1634            _ => out.push_str(&format!("%{b:02X}")),
1635        }
1636    }
1637    out
1638}
1639
1640// ---------------------------------------------------------------------------
1641// Reader: index
1642// ---------------------------------------------------------------------------
1643
1644/// Query for `GET /` — the scope + view selector.
1645#[derive(Debug, Deserialize, Default)]
1646struct IndexQuery {
1647    /// Filter to a single feed by its canonical URL.
1648    #[serde(default)]
1649    feed: Option<String>,
1650    /// Filter to a folder by its `at://` URI (shows every feed in the folder).
1651    #[serde(default)]
1652    folder: Option<String>,
1653    /// `unread` (default) | `all` | `starred`.
1654    #[serde(default)]
1655    view: Option<String>,
1656    /// 1-based page within the selected scope + view. Absent/0 means page 1.
1657    #[serde(default)]
1658    page: Option<u32>,
1659    /// Optional flash message (e.g. after an action redirect).
1660    #[serde(default)]
1661    flash: Option<String>,
1662}
1663
1664/// Rows per page in the reader's list views.
1665///
1666/// The list projection no longer carries article bodies ([`store::EntryListRow`]),
1667/// so a page is on the order of tens of kilobytes rather than the tens or
1668/// hundreds of megabytes an unbounded list of full entries could reach. The page
1669/// bound is the second half of that fix: without it, a reader with a long
1670/// backlog still decides how much memory a single request allocates.
1671const ENTRIES_PER_PAGE: i64 = 100;
1672
1673/// How many pages `total` entries occupy. An empty list is page 1 of 1, so the
1674/// pager reads "1 / 1" rather than "1 / 0".
1675fn page_count_for(total: i64) -> i64 {
1676    ((total + ENTRIES_PER_PAGE - 1) / ENTRIES_PER_PAGE).max(1)
1677}
1678
1679/// Ceiling on the reader's prev/next id list.
1680///
1681/// Unlike the page above, this genuinely spans the whole list — prev/next is the
1682/// reader's position within it — so it is bounded by count rather than paged. At
1683/// 8 bytes per id this is ~40 KB at the cap. Past it the neighbour links stop
1684/// resolving; the article itself still opens, and the list view still pages.
1685const PREV_NEXT_MAX: i64 = 5_000;
1686
1687/// Ceiling on the cached-starred identity set matched against PDS saved records.
1688///
1689/// Deliberately generous: under-reading this set makes a cached article look
1690/// uncached, and an uncached starred row's button deletes the PDS RECORD rather
1691/// than un-starring the entry. Truncating here would change what a click
1692/// destroys, so the cap exists only as a backstop against an absurd starred
1693/// count, not as a routine bound.
1694const STARRED_IDENTITY_MAX: i64 = 20_000;
1695
1696/// Most uncached PDS saved records this handler will hold in memory for one
1697/// request.
1698///
1699/// **A memory bound, not a visibility bound.** These rows are PAGED alongside
1700/// the cached entries, so `ENTRIES_PER_PAGE` decides how many are rendered and
1701/// this only caps how many are collected before slicing. An earlier version used
1702/// it to cap what was SHOWN, which left everything past it invisible and —
1703/// because the un-save control lives on the row, and nothing else in the app
1704/// lists these — unremovable.
1705///
1706/// Well above the PDS list ceiling's practical reach for one reader, so a reader
1707/// meeting it has thousands of saved records and gets a logged, ordered prefix
1708/// rather than a failure.
1709const MAX_UNCACHED_SAVED_ROWS: usize = 5_000;
1710
1711/// A subscription resolved against the local cache: the PDS record + its
1712/// (possibly-missing) cached feed row.
1713struct ResolvedSub {
1714    rkey: String,
1715    sub: Subscription,
1716    feed: Option<store::Feed>,
1717}
1718
1719/// Pull the user's subscriptions (source of truth = PDS), ensure each has a
1720/// local cache row so unread counts work, and return them resolved. Best-effort
1721/// on the sidecar: a failure falls back to the local cache alone.
1722async fn resolve_subscriptions(state: &AppState, did: &str) -> Vec<ResolvedSub> {
1723    resolve_subscriptions_noting(state, did).await.0
1724}
1725
1726/// What to tell a reader whose subscription list could not be read from their
1727/// PDS, so the last-known list being shown does not pass for a fresh one.
1728///
1729/// **A malformed record is named as such** (#177): the walk refuses rather than
1730/// drop that subscription, and "unreachable" would send the reader looking at
1731/// their network when the cause is a record some client wrote into their repo.
1732fn subscriptions_alert(err: &anyhow::Error) -> String {
1733    match err.downcast_ref::<crate::atproto::MalformedRecords>() {
1734        Some(m) => format!(
1735            "{} record(s) in your subscription list could not be read, so it was not \
1736             refreshed. Showing your last-known subscriptions; nothing was removed.",
1737            m.count
1738        ),
1739        None => "Your subscription list could not be read from your PDS just now. \
1740                 Showing your last-known subscriptions."
1741            .to_string(),
1742    }
1743}
1744
1745/// [`resolve_subscriptions`], plus the alert to show when the list shown is the
1746/// cached one because the PDS listing failed.
1747async fn resolve_subscriptions_noting(
1748    state: &AppState,
1749    did: &str,
1750) -> (Vec<ResolvedSub>, Option<String>) {
1751    let pool = &state.db;
1752    let subs = match state.repo().list_subscriptions_sorted(did).await {
1753        Ok(s) => s,
1754        Err(err) => {
1755            let alert = subscriptions_alert(&err);
1756            warn!(%err, %did, "could not list PDS subscriptions; showing this DID's cached subscriptions only");
1757            // Fail CLOSED: the PDS is the source of truth for what this DID
1758            // follows. When it is unreachable we must NOT widen the caller's
1759            // authorization surface. Serve from the DID's OWN last-known
1760            // `sub_ref` projection (its own feeds, possibly stale) and leave
1761            // `sub_ref` untouched — never synthesize from every cached feed,
1762            // which would grant cross-tenant read+mutate during any outage.
1763            // A DB failure here is NOT the same as "this DID follows nothing",
1764            // but `unwrap_or_default` rendered it as exactly that: an empty
1765            // sidebar and an empty reader, which arrives as "all my feeds
1766            // vanished". It still degrades to empty — there is nothing better to
1767            // show — but it says so, so the support ticket and the log line can
1768            // be matched up.
1769            let feeds = store::feeds_for_did(pool, did).await.unwrap_or_else(|err| {
1770                warn!(%err, %did, "the PDS is unreachable AND the local subscription \
1771                                   projection could not be read; rendering an EMPTY \
1772                                   feed list, which is not the same as having none");
1773                Vec::new()
1774            });
1775            let cached = feeds
1776                .into_iter()
1777                .map(|f| ResolvedSub {
1778                    rkey: String::new(),
1779                    sub: Subscription::new(f.url.clone(), now_rfc3339()),
1780                    feed: Some(f),
1781                })
1782                .collect();
1783            return (cached, Some(alert));
1784        }
1785    };
1786
1787    // **Deliberately NOT truncated to `max_subs_per_did`.**
1788    //
1789    // The PDS list is unbounded in practice — any client can write subscription
1790    // records, and only the 20,000-record list ceiling stops it — and the first
1791    // attempt at bounding it truncated the list right here. That was the wrong
1792    // place: `sync_sub_refs` below writes `sub_ref` from exactly this set, and
1793    // `sub_ref` is THE per-DID authorization hook, so dropping entries silently
1794    // removed the reader's ability to read OR mutate those feeds. A query-shape
1795    // problem would have become an access problem.
1796    //
1797    // The shape problem was the scope filter emitting one SQL placeholder per
1798    // feed; `store::list_query_sql` now passes the whole set as a single
1799    // `json_each` bind, so there is no size to defend against here and nothing
1800    // to truncate. `max_subs_per_did` stays what it is — a policy cap on ADDING
1801    // feeds — rather than becoming a silent read-time filter.
1802    let mut out = Vec::with_capacity(subs.len());
1803    for (rkey, sub) in subs {
1804        let feed = match store::get_feed_by_url(pool, &sub.url).await {
1805            Ok(Some(f)) => Some(f),
1806            Ok(None) => {
1807                // `sub.url` came out of an atproto record. The lexicon is open —
1808                // ANY client can write a subscription into a user's repo — so
1809                // this is untrusted input on the hot path of `GET /`, and it was
1810                // being stored with none of the three checks the add and import
1811                // paths apply. Two of those are capacity ceilings; this one is
1812                // the invariant in `FeedPrivacy`'s doc comment, which promises a
1813                // private feed URL is "never stored". Writing a
1814                // `…/feed/private/<token>` into the SHARED `feeds` table breaks
1815                // that promise even though `net::guarded_get` still refuses to
1816                // fetch it.
1817                if !feed::is_storable_feed_url(&sub.url, state.config.standard_site)
1818                    || feed::classify_feed_privacy(&sub.url).is_private()
1819                {
1820                    warn!(
1821                        %did,
1822                        "skipping cache row for a subscription URL that is private or not http(s)"
1823                    );
1824                    out.push(ResolvedSub {
1825                        rkey,
1826                        sub,
1827                        feed: None,
1828                    });
1829                    continue;
1830                }
1831                // Upsert a cache row so the sidebar reflects the real follow-list.
1832                //
1833                // A silent failure here is a support ticket with no evidence: no
1834                // `feeds` row means the poller never selects this subscription,
1835                // so the reader sees "I added a feed and it never updates" while
1836                // the PDS record looks perfect. Logged with the URL so the
1837                // failing subscription is identifiable.
1838                if let Err(err) = store::upsert_feed(
1839                    pool,
1840                    &store::NewFeed {
1841                        url: sub.url.clone(),
1842                        title: sub.title.clone(),
1843                        site_url: sub.site_url.clone(),
1844                        ..Default::default()
1845                    },
1846                )
1847                .await
1848                {
1849                    warn!(%err, url = %sub.url, %did, "could not cache a subscribed feed; \
1850                                                       it will not be polled");
1851                }
1852                store::get_feed_by_url(pool, &sub.url).await.ok().flatten()
1853            }
1854            Err(err) => {
1855                warn!(%err, url = %sub.url, "get_feed_by_url failed");
1856                None
1857            }
1858        };
1859        out.push(ResolvedSub { rkey, sub, feed });
1860    }
1861    // Mirror the caller's resolved subscription set into `sub_ref`, so every
1862    // scoped entry/feed read + read/star mutation authorizes against exactly
1863    // the feeds this DID follows right now. This is THE per-DID isolation hook.
1864    sync_sub_refs(pool, did, &out).await;
1865    (out, None)
1866}
1867
1868/// Refresh the `sub_ref` projection for `did` to exactly the feed ids present
1869/// in `subs`. Best-effort: a failure here only degrades the scoped reads (they
1870/// fail closed / show fewer rows), never leaks another user's entries.
1871async fn sync_sub_refs(pool: &store::Pool, did: &str, subs: &[ResolvedSub]) {
1872    let feed_ids: Vec<i64> = subs
1873        .iter()
1874        .filter_map(|s| s.feed.as_ref().map(|f| f.id))
1875        .collect();
1876    if let Err(err) = store::replace_sub_refs(pool, did, &feed_ids).await {
1877        warn!(%err, %did, "failed to sync sub_ref projection");
1878    }
1879}
1880
1881/// `GET /` — the reader. Renders the sidebar (folders + feeds from the PDS
1882/// records layer) and the article list for the selected scope + view.
1883async fn index(
1884    State(state): State<AppState>,
1885    headers: HeaderMap,
1886    Query(q): Query<IndexQuery>,
1887) -> Result<Response, WebError> {
1888    let user = match current_session(&state, &headers).await {
1889        Some(u) => u,
1890        // Signed out: serve the public landing page rather than bouncing to
1891        // /login. /login remains the entry point for the actual OAuth sign-in.
1892        None => {
1893            return Ok(render(&LandingTemplate {
1894                version: VERSION,
1895                repo_url: REPO_URL,
1896                crates_url: CRATES_URL,
1897                kofi_url: KOFI_URL,
1898            }))
1899        }
1900    };
1901    let did = user.did.clone();
1902    let pool = &state.db;
1903
1904    let (subs, alert) = resolve_subscriptions_noting(&state, &did).await;
1905
1906    // View: unread (default) | all | starred.
1907    let view = match q.view.as_deref() {
1908        Some("all") => "all",
1909        Some("starred") => "starred",
1910        _ => "unread",
1911    }
1912    .to_string();
1913    let list_view = list_view_of(q.view.as_deref());
1914
1915    // Which feed URLs are in scope?
1916    let scope_urls = scope_urls_for(&subs, q.feed.as_deref(), q.folder.as_deref());
1917    // …and the feed ids they resolve to. Scope is applied inside the query now,
1918    // so a page is a page of rows the reader will actually see. Filtering after
1919    // a `LIMIT` would have made pages arbitrarily short — sometimes empty — for
1920    // any scope narrower than the whole subscription list.
1921    let scope_ids = scoped_feed_ids(&subs, &scope_urls);
1922
1923    let feed_title_by_id = |id: i64| -> String {
1924        subs.iter()
1925            .find(|s| s.feed.as_ref().map(|f| f.id) == Some(id))
1926            .map(|s| {
1927                display_title(
1928                    s.sub
1929                        .title
1930                        .as_deref()
1931                        .or(s.feed.as_ref().and_then(|f| f.title.as_deref())),
1932                    &s.sub.url,
1933                )
1934            })
1935            .unwrap_or_default()
1936    };
1937
1938    // **One page of the chosen view, filtered, ordered and bounded in SQL.**
1939    //
1940    // All three views used to materialize every matching entry — `SELECT e.*`,
1941    // no `LIMIT`, article bodies included — and the "all" view additionally ran
1942    // one such query PER SUBSCRIBED FEED and merged the results in memory. None
1943    // of the row fields below read the body. See `store::EntryListRow`.
1944    // **Saved records the cache cannot show.**
1945    //
1946    // The starred view is built from local `entries`, so a saved record whose
1947    // article was never cached here is invisible — the case that matters is
1948    // starring in ANOTHER atproto reader, which is the portability the shared
1949    // lexicon exists for. Those rows are rendered from the PDS record alone.
1950    let mut uncached: Vec<EntryRow> = Vec::new();
1951    if view == "starred" {
1952        // **Match against every SUBSCRIBED cached starred entry, not `source`.**
1953        //
1954        // `source` has already been filtered by feed/folder. Matching against it
1955        // meant an entry that IS cached but sits outside the current filter
1956        // looked uncached — so it rendered as a "not cached" row whose star
1957        // button deletes the PDS RECORD instead of un-starring the entry. A
1958        // scope filter must not change what is destroyed. Paging is the same
1959        // hazard in a new form: matching against the visible PAGE would make
1960        // every cached article outside it look uncached. Hence a dedicated
1961        // identity query over the whole starred set — urls and guids only, no
1962        // bodies — rather than reusing `source`.
1963        //
1964        // One gap remains BY DESIGN, and is handled at the other end. This query
1965        // still carries the `sub_ref` predicate, so a starred, cached entry in a
1966        // feed the reader has UNSUBSCRIBED from is absent here and its record
1967        // renders as uncached. That is the right rendering — the article is no
1968        // longer part of any feed the reader follows, and the PDS record is what
1969        // still holds it — but it means the un-save button is the record-deleting
1970        // one. `unsave_record` therefore clears the local star too, so the two
1971        // stores agree however the row got classified. Dropping the predicate
1972        // here instead would have made the row link to `/entries/{id}`, which is
1973        // `sub_ref`-scoped and would 404.
1974        //
1975        // **Three ways this can be unusable, and all three fail CLOSED.** With an
1976        // incomplete identity set, a cached article looks uncached and renders an
1977        // un-save button that deletes the PDS RECORD. Showing no uncached rows
1978        // loses rows for one render; getting this wrong loses data permanently,
1979        // so every uncertain case suppresses them.
1980        let identities = match store::starred_identities(pool, &did, STARRED_IDENTITY_MAX).await {
1981            Ok(store::StarredIdentities::All(rows)) => Some(rows),
1982            // The cap is a memory backstop, and reaching it means the set is an
1983            // arbitrary subset. It used to return that subset with no way to
1984            // tell, so every starred article outside it got the destructive
1985            // button.
1986            Ok(store::StarredIdentities::Truncated) => {
1987                warn!(
1988                    %did,
1989                    cap = STARRED_IDENTITY_MAX,
1990                    "cached-starred set exceeded its cap; suppressing uncached saved rows \
1991                     rather than rendering record-deleting buttons for cached articles"
1992                );
1993                None
1994            }
1995            Err(err) => {
1996                warn!(%err, %did, "cached-starred identity lookup failed; \
1997                                    suppressing uncached saved rows this render");
1998                None
1999            }
2000        };
2001        // The escape hatch asks whether this DID has ANY cached starred entry —
2002        // not whether the current SCOPE does. `total` is narrowed by
2003        // `?feed=`/`?folder=` while the identity set spans every feed, so
2004        // comparing them waved the fail-closed condition through for any narrow
2005        // scope: a record whose `feedUrl` matched the filter while its cached
2006        // entry lived under another feed rendered as uncached.
2007        let identities_ok = identities.is_some();
2008        let identities = identities.unwrap_or_default();
2009        let cached_urls: std::collections::HashSet<&str> = identities
2010            .iter()
2011            .filter_map(|(url, _)| url.as_deref())
2012            .collect();
2013        let cached_guids: std::collections::HashSet<&str> =
2014            identities.iter().map(|(_, guid)| guid.as_str()).collect();
2015
2016        // Collected in full here, sliced per page later. They sort after every
2017        // cached row, so the two lists form one sequence that the pager walks —
2018        // see the slice below. Collected BEFORE the page is chosen because the
2019        // page count depends on how many there are.
2020        // Bounded like everything else on this page. These come from the PDS
2021        // (up to the list ceiling — 20,000 on the sidecar backend, 5,000 on
2022        // `backend=rust`, whose caps are a quarter of the other's) and are
2023        // appended whole to the last page, so `ENTRIES_PER_PAGE` does not
2024        // constrain them at all. The
2025        // cap is generous — a reader with more saved-elsewhere records than this
2026        // is not the case being designed for — but a response has to have a size
2027        // an operator can reason about.
2028        let mut uncached_dropped = 0usize;
2029        match state.repo().list_saved_sorted(&did).await {
2030            Ok(saved) if identities_ok => {
2031                for (rkey, item) in saved {
2032                    let known = cached_urls.contains(item.url.as_str())
2033                        || item
2034                            .entry_id
2035                            .as_deref()
2036                            .is_some_and(|g| cached_guids.contains(g));
2037                    if known {
2038                        continue;
2039                    }
2040                    // And the scope filter applies to these rows too. Without
2041                    // it, `?feed=X` still listed saved records from every other
2042                    // feed — the filter silently did nothing for them.
2043                    if let Some(urls) = &scope_urls {
2044                        match item.feed_url.as_deref() {
2045                            Some(feed_url) if urls.iter().any(|u| u == feed_url) => {}
2046                            // A saved record with no `feedUrl` cannot be placed
2047                            // in any feed's scope, so it belongs only to the
2048                            // unfiltered view.
2049                            _ => continue,
2050                        }
2051                    }
2052                    // **`safe_link` FIRST, and a failure no longer drops the row.**
2053                    //
2054                    // `item.url` is attacker-controlled — a saved record written
2055                    // by any client — and it lands in an `href`. Askama escapes
2056                    // HTML metacharacters but not SCHEMES, so `javascript:`
2057                    // survives escaping intact. This project already built the
2058                    // helper for exactly that, and `feed.rs` uses it on the
2059                    // equivalent link; this path was simply not routed through it.
2060                    //
2061                    // The real defect was what a failure DID: it `continue`d, so
2062                    // the row vanished entirely — no badge, no count, nothing —
2063                    // and the only trace was a `debug!` below any realistic
2064                    // filter. That makes the record unremovable FROM HERE, because
2065                    // the un-save button lives on the row; the reader has to open
2066                    // a different atproto client to get rid of it. A bad URL is a
2067                    // reason to withhold the LINK, not the row.
2068                    //
2069                    // The check also moved ABOVE the poll nudge. That is ordering
2070                    // hygiene rather than a fix: the nudge keys on `feed_url`, not
2071                    // on the URL being rejected here, and is already gated on the
2072                    // reader actually subscribing to that feed — so it was never
2073                    // reachable by an unusable `item.url`. Deciding whether a
2074                    // record is renderable before doing anything outbound on its
2075                    // behalf is simply the order that stays correct if either of
2076                    // those two facts later stops being true.
2077                    let link = SafeLink::external(&item.url);
2078                    if link.is_empty() {
2079                        warn!(
2080                            %did, %rkey,
2081                            "a saved record has an unusable URL; rendering it without a link \
2082                             so it can still be removed"
2083                        );
2084                    }
2085
2086                    // Opportunistic re-fetch: if the reader still subscribes to
2087                    // the feed, make it due now. If the article is still inside
2088                    // the feed's window the poller caches it normally and this
2089                    // row becomes a real entry on its own — no synthetic rows in
2090                    // the shared cache, which every subscriber would otherwise
2091                    // see as a content-less entry.
2092                    // **Bound the WORK, not just the response.** This check sat
2093                    // after the nudge and the `subs` scan below, so every render
2094                    // still walked all ≤20,000 PDS records, ran a subs-length
2095                    // string scan per record, and issued up to that many
2096                    // `mark_feed_due` round-trips on a 5-connection pool — then
2097                    // discarded everything past the cap. A cap that runs after
2098                    // the expensive part is a cap on the output only.
2099                    if uncached.len() >= MAX_UNCACHED_SAVED_ROWS {
2100                        uncached_dropped += 1;
2101                        continue;
2102                    }
2103                    if let Some(feed_url) = item.feed_url.as_deref() {
2104                        if subs.iter().any(|s| s.sub.url == feed_url) {
2105                            // Bounded to one nudge per feed per poll interval —
2106                            // see `mark_feed_due`. Unbounded, a reload loop here
2107                            // becomes outbound amplification.
2108                            let stale_before = (chrono::Utc::now()
2109                                - chrono::Duration::from_std(state.config.poll_interval)
2110                                    .unwrap_or_else(|_| chrono::Duration::hours(1)))
2111                            .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
2112                            if let Err(err) =
2113                                store::mark_feed_due(pool, feed_url, &stale_before).await
2114                            {
2115                                tracing::debug!(%err, %feed_url, "could not nudge a feed for a saved article");
2116                            }
2117                        }
2118                    }
2119                    uncached.push(EntryRow {
2120                        id: 0,
2121                        title: item
2122                            .title
2123                            .clone()
2124                            .filter(|t| !t.trim().is_empty())
2125                            // Falling back to the URL is fine for a link we are
2126                            // willing to render, and wrong for one we are not:
2127                            // it would put the exact string `safe_link` just
2128                            // rejected into the page as the record's name. The
2129                            // rkey is what the un-save button acts on, so it is
2130                            // the honest identifier for a row that has nothing
2131                            // else trustworthy to show.
2132                            .unwrap_or_else(|| {
2133                                if link.is_empty() {
2134                                    format!("Saved item {rkey}")
2135                                } else {
2136                                    item.url.clone()
2137                                }
2138                            }),
2139                        feed_title: item.feed_url.clone().unwrap_or_default(),
2140                        published: display_date(Some(&item.created_at)),
2141                        read: false,
2142                        starred: true,
2143                        // Empty = "render this row without an anchor". The
2144                        // template branches on it, so the rejected URL never
2145                        // reaches an `href` even as an escaped string.
2146                        link,
2147                        cached: false,
2148                        rkey,
2149                    });
2150                }
2151            }
2152            // Identity lookup was unusable — see the fail-closed note above.
2153            Ok(_) => {}
2154            Err(err) => warn!(%err, %did, "could not list saved records from the PDS"),
2155        }
2156        if uncached_dropped > 0 {
2157            warn!(
2158                %did,
2159                dropped = uncached_dropped,
2160                cap = MAX_UNCACHED_SAVED_ROWS,
2161                "more saved records than this instance will hold in one response; the \
2162                 rest are not reachable from here"
2163            );
2164        }
2165    }
2166
2167    // **One sequence, two sources.** The cached rows come from SQL, the uncached
2168    // PDS records follow them, and the pager walks the concatenation.
2169    //
2170    // The first version appended the uncached rows to the last page only and
2171    // kept them out of `total`, which left everything past a cap invisible AND
2172    // unremovable — the un-save button lives on the row, and there is no other
2173    // surface in the app that lists these. That is the same "unremovable FROM
2174    // HERE" hazard the `safe_link` fix above exists to prevent, reintroduced
2175    // forty lines later by a bound meant to protect memory.
2176    //
2177    // Paging the concatenation makes every record reachable and needs no cap on
2178    // what is RENDERED — one page is one page either way. The version before
2179    // that inflated `total` while clamping on the cached count, which advertised
2180    // a page the clamp could never reach; both numbers come from the same total
2181    // now, which is what makes that impossible rather than merely fixed.
2182    let total_cached =
2183        store::count_entries_for_view(pool, &did, list_view, scope_ids.as_deref()).await?;
2184    let uncached_len = uncached.len();
2185    let total = total_cached + uncached_len as i64;
2186    // Clamped to the range that exists. Past the end the list is empty, and the
2187    // empty state renders instead of the pager — which would strand a reader who
2188    // typed a page number, or who paged to the end and then marked entries read
2189    // out from under their own URL. Showing the last page is the answer to both.
2190    let page = i64::from(q.page.unwrap_or(1).max(1)).min(page_count_for(total));
2191    let offset = (page - 1) * ENTRIES_PER_PAGE;
2192    // Past the cached rows this returns nothing, which is exactly right: the
2193    // page is then made up entirely of uncached ones.
2194    let source = store::list_entries(
2195        pool,
2196        &did,
2197        list_view,
2198        scope_ids.as_deref(),
2199        ENTRIES_PER_PAGE,
2200        offset,
2201    )
2202    .await?;
2203    // **Both halves of the page are computed from the COUNT alone.**
2204    //
2205    // `total_cached` (a COUNT) and `source` (a SELECT) are separate unsynchronised
2206    // queries, so they can disagree about how many cached rows exist. Any part of
2207    // the page composition that reads `source.len()` inherits that disagreement.
2208    //
2209    // `cached_allotment` is this page's cached share according to the snapshot,
2210    // and it is what the uncached `skip`/`take` are derived from — so consecutive
2211    // pages tile the uncached list exactly, whichever way the count drifted.
2212    // `source` is then truncated to it only to avoid rendering rows the next page
2213    // will also claim.
2214    //
2215    // The previous version took `skip` from the count but `take` from
2216    // `source.len()`, which agreed only when the count UNDERSTATED. Overstating —
2217    // an un-star or a retention delete landing between the two queries — made
2218    // page N render `uncached[0..70]` while page N+1 rendered `uncached[50..80]`,
2219    // putting twenty rows, each carrying the record-DELETING un-save button, on
2220    // two pages at once. The comment claimed that shape was impossible; it was
2221    // merely rarer.
2222    let cached_allotment = (total_cached - offset).clamp(0, ENTRIES_PER_PAGE) as usize;
2223    let cached_here = cached_allotment.min(source.len());
2224    // Only compose when there is something to compose WITH. `uncached` is empty
2225    // on every view but `starred`, and truncating there just drops trailing rows
2226    // that no page then shows — the poller inserting between the COUNT and the
2227    // SELECT was enough to trigger it.
2228    let source = if uncached_len == 0 {
2229        &source[..]
2230    } else {
2231        &source[..cached_here]
2232    };
2233    let uncached_page: Vec<EntryRow> = {
2234        let skip = (offset - total_cached).max(0) as usize;
2235        let take = (ENTRIES_PER_PAGE as usize) - cached_allotment;
2236        uncached.into_iter().skip(skip).take(take).collect()
2237    };
2238    // This page's slice, used only to append below. The heading needs the
2239    // WHOLE-list figure, which is the set's size before slicing.
2240    let uncached_total = uncached_len as i64;
2241
2242    // The scope/view suffix carried onto every entry link (built once).
2243    let entry_scope_qs = {
2244        let mut parts = Vec::new();
2245        if let Some(f) = q.feed.as_deref() {
2246            parts.push(format!("feed={}", qenc(f)));
2247        }
2248        if let Some(f) = q.folder.as_deref() {
2249            parts.push(format!("folder={}", qenc(f)));
2250        }
2251        if view != "unread" {
2252            parts.push(format!("view={}", qenc(&view)));
2253        }
2254        parts.join("&")
2255    };
2256    let entries: Vec<EntryRow> = source
2257        .iter()
2258        .map(|e| EntryRow {
2259            id: e.id,
2260            title: e
2261                .title
2262                .clone()
2263                .filter(|t| !t.trim().is_empty())
2264                .unwrap_or_else(|| "(untitled)".to_string()),
2265            feed_title: feed_title_by_id(e.feed_id),
2266            published: display_date(e.published.as_deref()),
2267            // Both bits ride along on the row's own `entry_state` join now. They
2268            // used to be membership tests against the full unread and starred
2269            // sets, which is why those two lists were fetched in their entirety
2270            // on every render even when the page showed a hundred rows.
2271            read: e.read,
2272            starred: e.starred,
2273            link: SafeLink::entry(e.id, &entry_scope_qs),
2274            cached: true,
2275            rkey: String::new(),
2276        })
2277        .collect();
2278
2279    // The uncached slice for this page follows the cached rows.
2280    let mut entries = entries;
2281    entries.extend(uncached_page);
2282    let entries = entries;
2283
2284    let selected_feed = q.feed.as_deref();
2285    let selected_folder = q.folder.as_deref();
2286
2287    // Build the shared sidebar (folders + loose feeds, with unread counts).
2288    let (folder_views, loose_feeds, _folder_options) =
2289        build_sidebar(&state, &did, &subs, selected_feed, selected_folder).await;
2290
2291    // Heading + scope query-string suffix.
2292    let (heading, scope_qs) = if let Some(feed_url) = selected_feed {
2293        let name = subs
2294            .iter()
2295            .find(|s| s.sub.url == feed_url)
2296            .map(|s| {
2297                display_title(
2298                    s.sub
2299                        .title
2300                        .as_deref()
2301                        .or(s.feed.as_ref().and_then(|f| f.title.as_deref())),
2302                    &s.sub.url,
2303                )
2304            })
2305            .unwrap_or_else(|| display_title(None, feed_url));
2306        (name, format!("feed={}", qenc(feed_url)))
2307    } else if let Some(folder_uri) = selected_folder {
2308        let name = folder_views
2309            .iter()
2310            .find(|f| f.uri == folder_uri)
2311            .map(|f| f.name.clone())
2312            .unwrap_or_else(|| "Folder".to_string());
2313        (name, format!("folder={}", qenc(folder_uri)))
2314    } else {
2315        let h = match view.as_str() {
2316            "all" => "All",
2317            "starred" => "Starred",
2318            _ => "Unread",
2319        };
2320        (h.to_string(), String::new())
2321    };
2322
2323    let feed_scope = selected_feed.map(str::to_string);
2324    let nav = build_nav(&user, &view, scope_qs, folder_views, loose_feeds, false);
2325
2326    // Pager links. `entry_scope_qs` already carries feed/folder/view, so the
2327    // page number is the only thing appended — which keeps a paged link
2328    // identical to an unpaged one in every other respect.
2329    let page_href = |n: i64| -> String {
2330        let mut parts = Vec::new();
2331        if !entry_scope_qs.is_empty() {
2332            parts.push(entry_scope_qs.clone());
2333        }
2334        if n > 1 {
2335            parts.push(format!("page={n}"));
2336        }
2337        if parts.is_empty() {
2338            "/".to_string()
2339        } else {
2340            format!("/?{}", parts.join("&"))
2341        }
2342    };
2343    let prev_href = (page > 1).then(|| page_href(page - 1));
2344    let next_href = (page * ENTRIES_PER_PAGE < total).then(|| page_href(page + 1));
2345
2346    let tmpl = IndexTemplate {
2347        version: VERSION,
2348        repo_url: REPO_URL,
2349        kofi_url: KOFI_URL,
2350        flash: q.flash.unwrap_or_default(),
2351        alert: alert.unwrap_or_default(),
2352        nav,
2353        entries,
2354        heading,
2355        feed_scope,
2356        total,
2357        // Whole-list figure, so it sits beside `total` without double counting.
2358        // The per-page slice is composed above and is not a heading number.
2359        uncached_total,
2360        page,
2361        page_count: page_count_for(total),
2362        prev_href,
2363        next_href,
2364    };
2365    Ok(render(&tmpl))
2366}
2367
2368/// Query for `GET /manage` — carries an optional flash after an action redirect.
2369#[derive(Debug, Deserialize, Default)]
2370struct ManageQuery {
2371    #[serde(default)]
2372    flash: Option<String>,
2373}
2374
2375/// `GET /manage` — the feed-management page. Renders the rail plus the subscribe
2376/// / your-feeds / OPML surfaces; the forms POST to the existing routes
2377/// (`/subscriptions`, `/folders`, `/opml`, …). A read/render route only — no
2378/// mutation logic of its own.
2379async fn manage(
2380    State(state): State<AppState>,
2381    headers: HeaderMap,
2382    Query(q): Query<ManageQuery>,
2383) -> Result<Response, WebError> {
2384    let user = match current_session(&state, &headers).await {
2385        Some(u) => u,
2386        None => return Ok(Redirect::to("/login").into_response()),
2387    };
2388    let did = user.did.clone();
2389
2390    let (subs, alert) = resolve_subscriptions_noting(&state, &did).await;
2391    let (folder_views, loose_feeds, folder_options) =
2392        build_sidebar(&state, &did, &subs, None, None).await;
2393
2394    // Clone the sidebar for the rail; the page body reuses folders/loose feeds.
2395    let nav = build_nav(
2396        &user,
2397        "unread",
2398        String::new(),
2399        folder_views.iter().map(clone_folder_view).collect(),
2400        loose_feeds.iter().map(clone_feed_view).collect(),
2401        true,
2402    );
2403
2404    let tmpl = ManageTemplate {
2405        version: VERSION,
2406        repo_url: REPO_URL,
2407        kofi_url: KOFI_URL,
2408        flash: q.flash.unwrap_or_default(),
2409        alert: alert.unwrap_or_default(),
2410        nav,
2411        folder_options,
2412        folders: folder_views,
2413        loose_feeds,
2414    };
2415    Ok(render(&tmpl))
2416}
2417
2418/// Shallow clone helpers so `/manage` can hand the same sidebar to both the rail
2419/// (`Nav`) and the page body without an extra DB round-trip.
2420fn clone_feed_view(f: &FeedView) -> FeedView {
2421    FeedView {
2422        rkey: f.rkey.clone(),
2423        url: f.url.clone(),
2424        title: f.title.clone(),
2425        unread: f.unread,
2426        selected: f.selected,
2427        folder: f.folder.clone(),
2428    }
2429}
2430
2431fn clone_folder_view(f: &FolderView) -> FolderView {
2432    FolderView {
2433        rkey: f.rkey.clone(),
2434        uri: f.uri.clone(),
2435        name: f.name.clone(),
2436        feeds: f.feeds.iter().map(clone_feed_view).collect(),
2437        selected: f.selected,
2438    }
2439}
2440
2441/// The set of feed URLs a scope covers: `Some([one url])` for a single-feed
2442/// scope, `Some([urls…])` for a folder (its member feeds), or `None` for the
2443/// unscoped "everything" view. A folder scope takes the feed scope when both are
2444/// somehow present (feed wins, matching the query precedence elsewhere).
2445fn scope_urls_for(
2446    subs: &[ResolvedSub],
2447    feed: Option<&str>,
2448    folder: Option<&str>,
2449) -> Option<Vec<String>> {
2450    if let Some(feed_url) = feed {
2451        Some(vec![feed_url.to_string()])
2452    } else {
2453        folder.map(|folder_uri| {
2454            subs.iter()
2455                .filter(|s| s.sub.folder.as_deref() == Some(folder_uri))
2456                .map(|s| s.sub.url.clone())
2457                .collect()
2458        })
2459    }
2460}
2461
2462/// The `at://` URI for a folder record given the owner DID + rkey.
2463fn folder_uri(did: &str, rkey: &str) -> String {
2464    format!("at://{did}/{}/{rkey}", lexicon::nsid::FOLDER)
2465}
2466
2467/// Build the sidebar folder/loose-feed views (with per-feed unread counts) for a
2468/// DID — the shared source for both the reader index and the rail on every
2469/// chrome page. `selected_feed` / `selected_folder` drive `aria-current`.
2470async fn build_sidebar(
2471    state: &AppState,
2472    did: &str,
2473    subs: &[ResolvedSub],
2474    selected_feed: Option<&str>,
2475    selected_folder: Option<&str>,
2476) -> (Vec<FolderView>, Vec<FeedView>, Vec<FolderOption>) {
2477    let pool = &state.db;
2478    // Counted in SQL. This used to fetch every unread ENTRY — article bodies and
2479    // all — purely to `.filter().count()` them in Rust, on every page that
2480    // renders chrome, which made the sidebar the most frequently executed
2481    // instance of the unbounded-projection problem.
2482    let unread_counts = store::unread_counts_by_feed(pool, did)
2483        .await
2484        .unwrap_or_else(|err| {
2485            warn!(%err, %did, "sidebar unread counts failed; rendering zeroes");
2486            Default::default()
2487        });
2488    let folders = state
2489        .repo()
2490        .list_folders_sorted(did)
2491        .await
2492        .unwrap_or_default();
2493
2494    let unread_count = |feed_id: Option<i64>| -> i64 {
2495        feed_id
2496            .and_then(|id| unread_counts.get(&id).copied())
2497            .unwrap_or(0)
2498    };
2499    let mk_feed_view = |s: &ResolvedSub| FeedView {
2500        rkey: s.rkey.clone(),
2501        url: s.sub.url.clone(),
2502        title: display_title(
2503            s.sub
2504                .title
2505                .as_deref()
2506                .or(s.feed.as_ref().and_then(|f| f.title.as_deref())),
2507            &s.sub.url,
2508        ),
2509        unread: unread_count(s.feed.as_ref().map(|f| f.id)),
2510        selected: selected_feed == Some(s.sub.url.as_str()),
2511        folder: s.sub.folder.clone(),
2512    };
2513
2514    let mut folder_views = Vec::with_capacity(folders.len());
2515    for (rkey, folder) in &folders {
2516        let uri = folder_uri(did, rkey);
2517        let feeds: Vec<FeedView> = subs
2518            .iter()
2519            .filter(|s| s.sub.folder.as_deref() == Some(uri.as_str()))
2520            .map(mk_feed_view)
2521            .collect();
2522        folder_views.push(FolderView {
2523            rkey: rkey.clone(),
2524            uri: uri.clone(),
2525            name: folder.name.clone(),
2526            feeds,
2527            selected: selected_folder == Some(uri.as_str()),
2528        });
2529    }
2530
2531    let known_uris: std::collections::HashSet<String> =
2532        folders.iter().map(|(r, _)| folder_uri(did, r)).collect();
2533    let loose_feeds: Vec<FeedView> = subs
2534        .iter()
2535        .filter(|s| {
2536            s.sub
2537                .folder
2538                .as_deref()
2539                .map(|f| !known_uris.contains(f))
2540                .unwrap_or(true)
2541        })
2542        .map(mk_feed_view)
2543        .collect();
2544
2545    let folder_options: Vec<FolderOption> = folders
2546        .iter()
2547        .map(|(rkey, folder)| FolderOption {
2548            name: folder.name.clone(),
2549            uri: folder_uri(did, rkey),
2550        })
2551        .collect();
2552
2553    (folder_views, loose_feeds, folder_options)
2554}
2555
2556/// Assemble the shared rail [`Nav`] for a chrome page.
2557fn build_nav(
2558    user: &CurrentUser,
2559    view: &str,
2560    scope_qs: String,
2561    folders: Vec<FolderView>,
2562    loose_feeds: Vec<FeedView>,
2563    manage_active: bool,
2564) -> Nav {
2565    Nav {
2566        handle: display_handle(user.handle.as_deref(), &user.did),
2567        avatar: avatar_initials(user.handle.as_deref(), &user.did),
2568        view: view.to_string(),
2569        scope_qs,
2570        folders,
2571        loose_feeds,
2572        manage_active,
2573    }
2574}
2575
2576// ---------------------------------------------------------------------------
2577// Reader: single entry
2578// ---------------------------------------------------------------------------
2579
2580/// Query for `GET /entries/:id` — carries the reading context (scope + view) so
2581/// prev/next and "back" stay within the list the reader came from.
2582#[derive(Debug, Deserialize, Default)]
2583struct EntryQuery {
2584    #[serde(default)]
2585    feed: Option<String>,
2586    #[serde(default)]
2587    folder: Option<String>,
2588    #[serde(default)]
2589    view: Option<String>,
2590}
2591
2592/// `GET /entries/:id` — the clean reader view for one entry, with prev/next
2593/// within the current reading list.
2594async fn entry_view(
2595    State(state): State<AppState>,
2596    headers: HeaderMap,
2597    Path(id): Path<i64>,
2598    Query(q): Query<EntryQuery>,
2599) -> Result<Response, WebError> {
2600    let user = match current_session(&state, &headers).await {
2601        Some(u) => u,
2602        None => return Ok(Redirect::to("/login").into_response()),
2603    };
2604    let did = user.did.clone();
2605    let pool = &state.db;
2606
2607    // Resolve subscriptions FIRST: this refreshes the `sub_ref` projection so
2608    // the per-DID entry gate below authorizes against the caller's current PDS
2609    // subscription set (not another user's cached feeds).
2610    let subs = resolve_subscriptions(&state, &did).await;
2611
2612    let entry = match get_entry_by_id(pool, &did, id).await? {
2613        Some(e) => e,
2614        None => return Ok((StatusCode::NOT_FOUND, "entry not found").into_response()),
2615    };
2616
2617    let feed_title = feed_title_by_entry(pool, entry.feed_id).await;
2618
2619    let read = entry_is_read(pool, &did, id).await?;
2620    let starred = entry_is_starred(pool, &did, id).await?;
2621
2622    // Reconstruct the current list to compute prev/next, so paging in the reader
2623    // matches what the list showed.
2624    let (prev_id, next_id) = neighbors_in_scope(&state, &did, &q, id).await;
2625
2626    let back_qs = scope_query(&q);
2627
2628    let (folder_views, loose_feeds, _) =
2629        build_sidebar(&state, &did, &subs, q.feed.as_deref(), q.folder.as_deref()).await;
2630    let nav_view = match q.view.as_deref() {
2631        Some("all") => "all",
2632        Some("starred") => "starred",
2633        _ => "unread",
2634    };
2635    let nav = build_nav(
2636        &user,
2637        nav_view,
2638        back_qs.clone(),
2639        folder_views,
2640        loose_feeds,
2641        false,
2642    );
2643
2644    let tmpl = EntryTemplate {
2645        version: VERSION,
2646        repo_url: REPO_URL,
2647        kofi_url: KOFI_URL,
2648        nav,
2649        id: entry.id,
2650        title: entry
2651            .title
2652            .clone()
2653            .filter(|t| !t.trim().is_empty())
2654            .unwrap_or_else(|| "(untitled)".to_string()),
2655        feed_title,
2656        author: entry.author.clone().filter(|a| !a.trim().is_empty()),
2657        published: display_date(entry.published.as_deref()),
2658        url: entry.url.as_deref().and_then(SafeLink::external_opt),
2659        content_html: entry.content_html.clone(),
2660        read,
2661        starred,
2662        back_qs,
2663        prev_id,
2664        next_id,
2665        oob: false,
2666    };
2667    Ok(render(&tmpl))
2668}
2669
2670/// Compute the prev/next entry ids around `current` within the reader's current
2671/// scope + view, so the reader view can offer keyboard/paging navigation.
2672async fn neighbors_in_scope(
2673    state: &AppState,
2674    did: &str,
2675    q: &EntryQuery,
2676    current: i64,
2677) -> (Option<i64>, Option<i64>) {
2678    let idx_q = IndexQuery {
2679        feed: q.feed.clone(),
2680        folder: q.folder.clone(),
2681        view: q.view.clone(),
2682        // Neighbours span the whole list, not the page the reader arrived from.
2683        page: None,
2684        flash: None,
2685    };
2686    let ids = list_entry_ids(state, did, &idx_q).await;
2687    let pos = ids.iter().position(|&x| x == current);
2688    match pos {
2689        Some(p) => {
2690            let prev = if p > 0 { Some(ids[p - 1]) } else { None };
2691            let next = ids.get(p + 1).copied();
2692            (prev, next)
2693        }
2694        None => (None, None),
2695    }
2696}
2697
2698/// The ordered entry ids for a scope + view — the same ordering `index` renders,
2699/// used for reader prev/next. Best-effort; PDS failures degrade to local cache.
2700async fn list_entry_ids(state: &AppState, did: &str, q: &IndexQuery) -> Vec<i64> {
2701    let pool = &state.db;
2702    let subs = resolve_subscriptions(state, did).await;
2703
2704    let scope_urls = scope_urls_for(&subs, q.feed.as_deref(), q.folder.as_deref());
2705
2706    // Ids only, and bounded. This used to fetch whole entries — bodies included
2707    // — for all three views and then throw everything but `id` away; the "all"
2708    // branch additionally ran one unbounded query PER FEED and sorted the union
2709    // in memory. Scope is now a feed-id restriction inside the query, so the
2710    // database does the filtering and the ordering exactly once.
2711    store::list_entry_ids(
2712        pool,
2713        did,
2714        list_view_of(q.view.as_deref()),
2715        scoped_feed_ids(&subs, &scope_urls).as_deref(),
2716        PREV_NEXT_MAX,
2717    )
2718    .await
2719    .unwrap_or_else(|err| {
2720        warn!(%err, %did, "prev/next id list failed; the reader loses its neighbour links");
2721        Vec::new()
2722    })
2723}
2724
2725/// Map the `?view=` query value onto the store's list view. Anything
2726/// unrecognised is the unread default, matching `index`.
2727fn list_view_of(view: Option<&str>) -> store::ListView {
2728    match view {
2729        Some("all") => store::ListView::All,
2730        Some("starred") => store::ListView::Starred,
2731        _ => store::ListView::Unread,
2732    }
2733}
2734
2735/// Translate a feed/folder scope into the feed ids to restrict a list query to.
2736///
2737/// `None` means unscoped (every subscribed feed). `Some(&[])` means the scope
2738/// matched no local feed, which must return nothing rather than everything — so
2739/// the empty vec is deliberately preserved, not collapsed back into `None`.
2740fn scoped_feed_ids(subs: &[ResolvedSub], scope_urls: &Option<Vec<String>>) -> Option<Vec<i64>> {
2741    let urls = scope_urls.as_ref()?;
2742    Some(
2743        subs.iter()
2744            .filter(|s| urls.contains(&s.sub.url))
2745            .filter_map(|s| s.feed.as_ref().map(|f| f.id))
2746            .collect(),
2747    )
2748}
2749
2750/// Build a `?…` query string that preserves the reading scope + view for links.
2751fn scope_query(q: &EntryQuery) -> String {
2752    let mut parts = Vec::new();
2753    if let Some(f) = q.feed.as_deref() {
2754        parts.push(format!("feed={}", qenc(f)));
2755    }
2756    if let Some(f) = q.folder.as_deref() {
2757        parts.push(format!("folder={}", qenc(f)));
2758    }
2759    if let Some(v) = q.view.as_deref() {
2760        if v != "unread" {
2761            parts.push(format!("view={}", qenc(v)));
2762        }
2763    }
2764    parts.join("&")
2765}
2766
2767// ---------------------------------------------------------------------------
2768// Mark read / unread
2769// ---------------------------------------------------------------------------
2770
2771/// Form body for `POST /entries/:id/read`.
2772#[derive(Debug, Deserialize)]
2773struct ReadForm {
2774    #[serde(default)]
2775    read: Option<String>,
2776}
2777
2778/// `POST /entries/:id/read` — toggle an entry's read-state for the current DID.
2779async fn mark_read(
2780    State(state): State<AppState>,
2781    Path(id): Path<i64>,
2782    headers: HeaderMap,
2783    Form(form): Form<ReadForm>,
2784) -> Result<Response, WebError> {
2785    let did = match current_did(&state, &headers).await {
2786        Some(d) => d,
2787        None => return Ok(Redirect::to("/login").into_response()),
2788    };
2789    let pool = &state.db;
2790
2791    let read = matches!(
2792        form.read.as_deref(),
2793        Some("true") | Some("1") | Some("on") | None
2794    );
2795
2796    // Refresh the caller's `sub_ref` projection, then apply the AUTHORIZED
2797    // mutation: `mark_read` only writes when `did` subscribes to the entry's
2798    // feed. A non-subscriber gets a 404, never a mutation of someone else's
2799    // (or the shared cache's) state.
2800    resolve_subscriptions(&state, &did).await;
2801    if !store::mark_read(pool, &did, id, read).await? {
2802        return Ok((StatusCode::NOT_FOUND, "entry not found").into_response());
2803    }
2804
2805    if !is_htmx(&headers) {
2806        return Ok(Redirect::to("/").into_response());
2807    }
2808
2809    // The reader view swaps an out-of-band action-bar fragment (its `<li>` isn't
2810    // in the DOM), so its button's hidden value + aria-pressed update in place
2811    // and a second keypress can reverse the toggle. The list view swaps the row.
2812    if is_reader_request(&headers) {
2813        let starred = entry_is_starred(pool, &did, id).await?;
2814        return Ok(render(&EntryActionBarTemplate {
2815            id,
2816            read,
2817            starred,
2818            oob: true,
2819        }));
2820    }
2821
2822    let row = build_entry_row(pool, &did, id, Some(read)).await?;
2823    match row {
2824        Some(r) => Ok(render(&EntryRowTemplate { e: r })),
2825        None => Ok((StatusCode::NOT_FOUND, "entry not found").into_response()),
2826    }
2827}
2828
2829// ---------------------------------------------------------------------------
2830// Star / save
2831// ---------------------------------------------------------------------------
2832
2833/// Form body for `POST /entries/:id/star`.
2834#[derive(Debug, Deserialize)]
2835struct StarForm {
2836    #[serde(default)]
2837    starred: Option<String>,
2838}
2839
2840/// `POST /entries/:id/star` — star/unstar an entry.
2841///
2842/// Sets the local `starred` bit (fast working copy) and writes/removes a
2843/// `community.lexicon.rss.saved` record in the user's PDS (stars are worth
2844/// owning). The PDS write is best-effort — the local star still lands.
2845async fn toggle_star(
2846    State(state): State<AppState>,
2847    Path(id): Path<i64>,
2848    headers: HeaderMap,
2849    Form(form): Form<StarForm>,
2850) -> Result<Response, WebError> {
2851    let did = match current_did(&state, &headers).await {
2852        Some(d) => d,
2853        None => return Ok(Redirect::to("/login").into_response()),
2854    };
2855    let pool = &state.db;
2856
2857    let starred = matches!(
2858        form.starred.as_deref(),
2859        Some("true") | Some("1") | Some("on") | None
2860    );
2861
2862    // Refresh the caller's `sub_ref` projection, then apply the AUTHORIZED
2863    // mutation: `mark_starred` only writes when `did` subscribes to the entry's
2864    // feed. A non-subscriber gets a 404, never a mutation.
2865    resolve_subscriptions(&state, &did).await;
2866    if !store::mark_starred(pool, &did, id, starred).await? {
2867        return Ok((StatusCode::NOT_FOUND, "entry not found").into_response());
2868    }
2869
2870    // Reflect into the PDS saved-records collection. `get_entry_by_id` is scoped
2871    // to the caller's subscriptions, so this only ever acts on the caller's feed.
2872    if let Ok(Some(entry)) = get_entry_by_id(pool, &did, id).await {
2873        let entry_url = entry.url.clone().unwrap_or_default();
2874        if !entry_url.is_empty() {
2875            if starred {
2876                let mut saved = Saved::new(entry_url.clone(), now_rfc3339());
2877                saved.title = entry.title.clone();
2878                saved.feed_url = feed_url_for_id(pool, entry.feed_id).await;
2879                saved.entry_id = Some(entry.guid.clone());
2880                match state.repo().add_saved(&did, &saved).await {
2881                    Ok(rkey) => info!(%did, url = %entry_url, %rkey, "wrote saved record to PDS"),
2882                    Err(err) => warn!(%err, %did, "PDS saved write failed (starred locally)"),
2883                }
2884            } else {
2885                // Un-star: find and delete the matching saved record by URL.
2886                match state.repo().list_saved(&did).await {
2887                    Ok(records) => {
2888                        for (rkey, _rec) in records.iter().filter(|(_, r)| r.url == entry_url) {
2889                            if let Err(err) = state.repo().remove_saved(&did, rkey).await {
2890                                warn!(%err, %did, %rkey, "PDS saved delete failed");
2891                            }
2892                        }
2893                    }
2894                    Err(err) => warn!(%err, %did, "could not list saved records to un-star"),
2895                }
2896            }
2897        }
2898    }
2899
2900    if !is_htmx(&headers) {
2901        return Ok(Redirect::to("/").into_response());
2902    }
2903
2904    // Reader → out-of-band action-bar fragment; list → the row (see mark_read).
2905    if is_reader_request(&headers) {
2906        let read = entry_is_read(pool, &did, id).await?;
2907        return Ok(render(&EntryActionBarTemplate {
2908            id,
2909            read,
2910            starred,
2911            oob: true,
2912        }));
2913    }
2914
2915    let row = build_entry_row(pool, &did, id, None).await?;
2916    match row {
2917        Some(r) => Ok(render(&EntryRowTemplate { e: r })),
2918        None => Ok((StatusCode::NOT_FOUND, "entry not found").into_response()),
2919    }
2920}
2921
2922/// The feed URL for a cached feed id, if the row exists.
2923async fn feed_url_for_id(pool: &store::Pool, feed_id: i64) -> Option<String> {
2924    sqlx::query_scalar::<_, String>("SELECT url FROM feeds WHERE id = ?1")
2925        .bind(feed_id)
2926        .fetch_optional(pool)
2927        .await
2928        .ok()
2929        .flatten()
2930}
2931
2932// ---------------------------------------------------------------------------
2933// Mark-all-read
2934// ---------------------------------------------------------------------------
2935
2936/// Query for `POST /read-all` — an optional `?feed=<url>` scopes it to one feed;
2937/// absent means mark everything read.
2938#[derive(Debug, Deserialize, Default)]
2939struct ReadAllQuery {
2940    #[serde(default)]
2941    feed: Option<String>,
2942}
2943
2944/// `POST /read-all` — mark every entry read for the current DID, optionally
2945/// scoped to one feed (mark-all-read per feed or globally).
2946async fn mark_all_read(
2947    State(state): State<AppState>,
2948    headers: HeaderMap,
2949    Query(q): Query<ReadAllQuery>,
2950) -> Result<Response, WebError> {
2951    let did = match current_did(&state, &headers).await {
2952        Some(d) => d,
2953        None => return Ok(Redirect::to("/login").into_response()),
2954    };
2955    let pool = &state.db;
2956
2957    // Refresh the caller's `sub_ref` projection so the scoped mark-read writes
2958    // only ever touch feeds this DID actually subscribes to.
2959    resolve_subscriptions(&state, &did).await;
2960
2961    if let Some(feed_url) = q.feed.as_deref() {
2962        if let Ok(Some(feed)) = store::get_feed_by_url(pool, feed_url).await {
2963            store::mark_feed_read(pool, &did, feed.id, true).await?;
2964        }
2965        return Ok(Redirect::to(&format!("/?feed={}", qenc(feed_url))).into_response());
2966    }
2967
2968    // Global: mark every subscribed feed read. Fan out over the DID's feeds
2969    // (bounded by the per-DID subscription cap) using the batched per-feed path,
2970    // rather than one UPDATE round-trip per unread entry (unbounded) — same end
2971    // state, but O(feeds) statements instead of O(unread entries).
2972    for feed_id in store::subscribed_feed_ids(pool, &did).await? {
2973        store::mark_feed_read(pool, &did, feed_id, true).await?;
2974    }
2975    Ok(Redirect::to("/").into_response())
2976}
2977
2978// ---------------------------------------------------------------------------
2979// Subscribe by URL
2980// ---------------------------------------------------------------------------
2981
2982/// Flash for a URL this instance cannot store as a feed — not private, just
2983/// not a kind of feed it supports (an `at://` publication with
2984/// `FEATHERREADER_STANDARD_SITE` off, an unsupported scheme). Distinct from
2985/// [`PRIVATE_FEED_REFUSAL`], whose "not saved or sent anywhere" would be a
2986/// false promise for a record that may already exist in the user's PDS.
2987const UNSUPPORTED_FEED_URL_REFUSAL: &str =
2988    "That isn't a kind of feed this instance can subscribe to. Nothing was saved.";
2989
2990/// Shown when an OPML export is refused because the subscription list could not
2991/// be read in full.
2992///
2993/// **An empty export is worse than no export.** This path used to
2994/// `unwrap_or_default()`, so a failed read produced a 200 carrying a zero-feed
2995/// file — a blank backup, handed over at the moment the reader reached for one.
2996const EXPORT_INCOMPLETE_REFUSAL: &str =
2997    "Could not read your subscriptions in full, so nothing was exported. Your \
2998     feeds are unchanged — try again, and if it keeps failing the list may be \
2999     larger than this reader can page through.";
3000
3001/// Refusal message shown when a private/paid feed is submitted. FeatherReader
3002/// stores subscriptions in the user's PUBLIC PDS, so it supports public feeds
3003/// only for now — a private feed's secret URL is never saved, fetched, or sent
3004/// anywhere. Kept as a constant so the add and OPML paths share the exact wording
3005/// and the boot-smoke can assert on it.
3006const PRIVATE_FEED_REFUSAL: &str = "Private/paid feeds aren't supported yet. \
3007    FeatherReader stores your subscriptions in your public PDS, so it supports public \
3008    feeds for now — private-feed support arrives when atproto's private data \
3009    (permissioned records) ships. Your feed URL was not saved or sent anywhere.";
3010
3011/// Form body for `POST /subscriptions`.
3012#[derive(Debug, Deserialize)]
3013struct SubscribeForm {
3014    url: String,
3015    /// Optional folder `at://` URI to file the new feed under.
3016    #[serde(default)]
3017    folder: Option<String>,
3018}
3019
3020/// The DID-form URL to store for a pasted `at://` publication, or the flash
3021/// to refuse it with.
3022///
3023/// - The scheme is canonicalised: `At://` is the same publication, and
3024///   storing a second spelling makes a second row for it (#183).
3025/// - It must name a `site.standard.publication`; anything else is not a feed
3026///   this instance can read.
3027/// - A handle is resolved to its DID: a handle is a mutable name, and
3028///   `feeds.url` is keyed on identity, so only the DID form is stored.
3029async fn publication_url_from_paste(state: &AppState, input: &str) -> Result<String, String> {
3030    let unsupported = || UNSUPPORTED_FEED_URL_REFUSAL.to_string();
3031    let canonical = format!(
3032        "{}{}",
3033        crate::atproto::AT_URI_PREFIX,
3034        &input[crate::atproto::AT_URI_PREFIX.len()..]
3035    );
3036    let uri = crate::standard_site::AtUri::parse(&canonical).ok_or_else(unsupported)?;
3037    if uri.collection != lexicon::nsid::STANDARD_PUBLICATION {
3038        return Err(unsupported());
3039    }
3040    let did = if crate::oauth::identity::is_atproto_did(&uri.authority) {
3041        uri.authority.clone()
3042    } else {
3043        let handle =
3044            // Validated as a handle before it is sent anywhere: an authority
3045            // that is neither a DID nor a handle (`did:plc:TOOSHORT`, an
3046            // uppercase DID, a newline) is unsupported, not a lookup (found in
3047            // review).
3048            crate::oauth::identity::normalize_handle(&uri.authority).map_err(|_| unsupported())?;
3049        crate::atproto::resolve_handle(&state.http, &state.config.resolver_base, &handle)
3050            .await
3051            .map_err(|err| {
3052                warn!(%err, handle = %uri.authority, "could not resolve a pasted publication's handle");
3053                format!("Couldn't resolve the handle {} to an account.", uri.authority)
3054            })?
3055    };
3056    let url = format!(
3057        "{}{did}/{}/{}",
3058        crate::atproto::AT_URI_PREFIX,
3059        uri.collection,
3060        uri.rkey
3061    );
3062    if !feed::is_storable_feed_url(&url, true) {
3063        return Err(unsupported());
3064    }
3065    Ok(url)
3066}
3067
3068/// `POST /subscriptions` — subscribe by URL.
3069async fn add_subscription(
3070    State(state): State<AppState>,
3071    headers: HeaderMap,
3072    Form(form): Form<SubscribeForm>,
3073) -> Result<Response, WebError> {
3074    let did = match current_did(&state, &headers).await {
3075        Some(d) => d,
3076        None => return Ok(Redirect::to("/login").into_response()),
3077    };
3078    let pool = &state.db;
3079    let input = form.url.trim().to_string();
3080    if input.is_empty() {
3081        return Ok(Redirect::to("/").into_response());
3082    }
3083
3084    // Per-DID subscription cap: bound one account's storage/poller footprint on
3085    // the small box. Checked BEFORE any fetch/resolve so an over-cap account
3086    // can't even trigger an outbound request. `<= 0` disables the cap.
3087    let cap = state.config.max_subs_per_did;
3088    if cap > 0 {
3089        match store::count_subscriptions_for_did(pool, &did).await {
3090            Ok(n) if n >= cap => {
3091                info!(%did, current = n, cap, "refused subscribe: per-DID subscription cap reached");
3092                return Ok(Redirect::to(&format!(
3093                    "/?flash={}",
3094                    qenc(&format!(
3095                        "Subscription limit reached ({cap}). Remove a feed before adding another."
3096                    ))
3097                ))
3098                .into_response());
3099            }
3100            Ok(_) => {}
3101            Err(err) => warn!(%err, %did, "could not count subscriptions for cap check; allowing"),
3102        }
3103    }
3104
3105    // **An at:// paste is a standard.site publication, read by the poller
3106    // since 0.4.0**: canonicalised and resolved to its DID form here, then it
3107    // joins the ordinary path below. With the flag off it is refused as it
3108    // always was — the flag gates what may be stored.
3109    let is_at_uri = input
3110        .get(..crate::atproto::AT_URI_PREFIX.len())
3111        .is_some_and(|p| p.eq_ignore_ascii_case(crate::atproto::AT_URI_PREFIX));
3112    let publication_url = if is_at_uri {
3113        if !state.config.standard_site {
3114            info!(url = %input, %did, "refused an at:// paste: standard.site is off (not stored)");
3115            return Ok(
3116                Redirect::to(&format!("/?flash={}", qenc(UNSUPPORTED_FEED_URL_REFUSAL)))
3117                    .into_response(),
3118            );
3119        }
3120        match publication_url_from_paste(&state, &input).await {
3121            Ok(url) => Some(url),
3122            Err(flash) => {
3123                info!(url = %input, %did, %flash, "refused an at:// paste (not stored)");
3124                return Ok(Redirect::to(&format!("/?flash={}", qenc(&flash))).into_response());
3125            }
3126        }
3127    } else {
3128        None
3129    };
3130
3131    if let feed::FeedPrivacy::Private(reason) =
3132        feed::classify_feed_privacy(publication_url.as_deref().unwrap_or(&input))
3133    {
3134        info!(url = %input, %reason, %did, "refused private/paid feed at add (not fetched or stored)");
3135        return Ok(
3136            Redirect::to(&format!("/?flash={}", qenc(PRIVATE_FEED_REFUSAL))).into_response(),
3137        );
3138    }
3139
3140    let resolved = match publication_url {
3141        Some(url) => Ok(url),
3142        None => resolve_feed_url(&state.config, &input).await,
3143    };
3144    let feed_url = match resolved {
3145        Ok(u) => u,
3146        Err(err) => {
3147            warn!(%err, url = %input, "could not resolve a feed from the given URL");
3148            return Ok(Redirect::to(&format!(
3149                "/?flash={}",
3150                qenc("Couldn't find a feed at that URL")
3151            ))
3152            .into_response());
3153        }
3154    };
3155
3156    // Defensive: resolution may have discovered a feed URL that itself carries a
3157    // secret (e.g. a public site page linking a tokened feed). Re-check the
3158    // resolved URL and refuse before storing/writing anything.
3159    if let feed::FeedPrivacy::Private(reason) = feed::classify_feed_privacy(&feed_url) {
3160        info!(url = %feed_url, %reason, %did, "refused private/paid feed after resolution (not stored)");
3161        return Ok(
3162            Redirect::to(&format!("/?flash={}", qenc(PRIVATE_FEED_REFUSAL))).into_response(),
3163        );
3164    }
3165
3166    // The URL about to be STORED is what must be storable — not the one the
3167    // user typed. Autodiscovery already yields only http(s), but this is the
3168    // path that writes the row and the PDS record, so the check lives here too:
3169    // the same gate the OPML and rename paths apply, on the same terms.
3170    if !feed::is_storable_feed_url(&feed_url, state.config.standard_site) {
3171        info!(url = %feed_url, %did, "refused unsupported feed URL after resolution (not stored)");
3172        return Ok(
3173            Redirect::to(&format!("/?flash={}", qenc(UNSUPPORTED_FEED_URL_REFUSAL)))
3174                .into_response(),
3175        );
3176    }
3177
3178    // Global feeds ceiling: a brand-new distinct feed is refused once the shared
3179    // cache is full (an existing/duplicate feed URL is always fine — it adds no
3180    // row). Bounds total cache size across all users on the box. `<= 0` disables.
3181    let feeds_cap = state.config.max_feeds_global;
3182    if feeds_cap > 0 && store::get_feed_by_url(pool, &feed_url).await?.is_none() {
3183        match store::count_feeds(pool).await {
3184            Ok(n) if n >= feeds_cap => {
3185                warn!(%did, feeds = n, cap = feeds_cap, feed = %feed_url, "refused subscribe: global feeds ceiling reached");
3186                return Ok(Redirect::to(&format!(
3187                    "/?flash={}",
3188                    qenc(
3189                        "This instance is at its feed capacity right now. Please try again later."
3190                    )
3191                ))
3192                .into_response());
3193            }
3194            Ok(_) => {}
3195            Err(err) => warn!(%err, "could not count feeds for global-cap check; allowing"),
3196        }
3197    }
3198
3199    store::upsert_feed(
3200        pool,
3201        &store::NewFeed {
3202            url: feed_url.clone(),
3203            ..Default::default()
3204        },
3205    )
3206    .await?;
3207
3208    if let Ok(client) = feed::build_client() {
3209        if let Some(feed_row) = store::get_feed_by_url(pool, &feed_url).await? {
3210            match feed::poll_feed_by_kind(pool, &client, &state.config, &feed_row).await {
3211                Ok(outcome) => {
3212                    info!(feed = %feed_url, ?outcome, "polled new subscription");
3213                    // **This path is not the scheduler, so it must settle the
3214                    // error columns itself.** `poll_feed` writes validators and
3215                    // `last_polled` and nothing else.
3216                    feed::settle_poll(pool, &feed_url, &outcome, state.config.poll_interval).await;
3217                }
3218                Err(err) => warn!(%err, feed = %feed_url, "initial poll failed"),
3219            }
3220        }
3221    }
3222
3223    let mut sub = Subscription::new(feed_url.clone(), now_rfc3339());
3224    if let Ok(Some(feed_row)) = store::get_feed_by_url(pool, &feed_url).await {
3225        sub.title = feed_row.title.clone();
3226        sub.site_url = feed_row.site_url.clone();
3227    }
3228    sub.folder = form
3229        .folder
3230        .map(|f| f.trim().to_string())
3231        .filter(|f| !f.is_empty());
3232
3233    match state.repo().add_subscription(&did, &sub).await {
3234        Ok(rkey) => info!(feed = %feed_url, %rkey, %did, "wrote subscription record to PDS"),
3235        Err(err) => {
3236            warn!(%err, feed = %feed_url, %did, "PDS subscription write failed (cached locally)")
3237        }
3238    }
3239
3240    Ok(Redirect::to("/").into_response())
3241}
3242
3243/// `POST /subscriptions/:rkey/delete` — unsubscribe (delete the PDS record).
3244async fn delete_subscription(
3245    State(state): State<AppState>,
3246    headers: HeaderMap,
3247    Path(rkey): Path<String>,
3248) -> Result<Response, WebError> {
3249    let did = match current_did(&state, &headers).await {
3250        Some(d) => d,
3251        None => return Ok(Redirect::to("/login").into_response()),
3252    };
3253    match state.repo().remove_subscription(&did, &rkey).await {
3254        Ok(()) => info!(%did, %rkey, "unsubscribed (deleted PDS subscription record)"),
3255        Err(err) => warn!(%err, %did, %rkey, "PDS unsubscribe failed"),
3256    }
3257    Ok(Redirect::to("/").into_response())
3258}
3259
3260/// Form body for `POST /subscriptions/:rkey/rename`.
3261#[derive(Debug, Deserialize)]
3262struct RenameSubForm {
3263    url: String,
3264    #[serde(default)]
3265    title: Option<String>,
3266    #[serde(default)]
3267    site_url: Option<String>,
3268    #[serde(default)]
3269    folder: Option<String>,
3270}
3271
3272/// `POST /subscriptions/:rkey/rename` — retitle a feed and/or move it to a
3273/// folder, rewriting the whole subscription record via `putRecord`.
3274async fn rename_subscription(
3275    State(state): State<AppState>,
3276    headers: HeaderMap,
3277    Path(rkey): Path<String>,
3278    Form(form): Form<RenameSubForm>,
3279) -> Result<Response, WebError> {
3280    let did = match current_did(&state, &headers).await {
3281        Some(d) => d,
3282        None => return Ok(Redirect::to("/login").into_response()),
3283    };
3284    let feed_url = form.url.trim().to_string();
3285
3286    // Reject an empty/blank resolved URL — a rename with no usable URL must not
3287    // write a junk row to the cache or a malformed subscription record to the
3288    // PDS (add_subscription refuses an empty input the same way).
3289    if feed_url.is_empty() {
3290        return Ok(Redirect::to("/").into_response());
3291    }
3292
3293    // **Read before write — `update_subscription` is a `putRecord`, and a
3294    // putRecord replaces the WHOLE record** (see its doc on `atproto.rs`).
3295    //
3296    // This used to build a fresh `Subscription::new(feed_url, now_rfc3339())`
3297    // and hand that over, so every field the form does not carry was written
3298    // back as its default. `templates/manage_row.html` posts `url`, `title` and
3299    // `folder` — and nothing else — so a rename silently destroyed four fields:
3300    // `siteUrl`, `fetchHint`, `private`, and `createdAt`.
3301    //
3302    // `createdAt` is the one that matters most: it is the reader's subscribe
3303    // time, it is the sort key for "when did I subscribe", it lives in THEIR
3304    // repo rather than our cache, and once overwritten it is gone with nothing
3305    // in the UI to say so.
3306    //
3307    // There is no single-record read on `Repo` (no `getRecord`), so this lists
3308    // and filters. That is one extra round trip on an action that is already
3309    // doing a PDS write, and it is bounded; a `get_subscription` would be
3310    // strictly better if this ever measures badly.
3311    //
3312    // **A failed read refuses the rename.** Falling back to the old
3313    // rebuild-from-scratch here would reinstate the data loss on exactly the
3314    // flaky path, which is the worst place to have it. The write below already
3315    // takes this stance — "a failure here means nothing was renamed or moved" —
3316    // and the read gets the same one.
3317    let existing = match state.repo().list_subscriptions_sorted(&did).await {
3318        Ok(subs) => subs.into_iter().find(|(k, _)| *k == rkey).map(|(_, s)| s),
3319        Err(err) => {
3320            warn!(%err, %did, %rkey, "could not read the subscription before renaming it");
3321            return Ok(Redirect::to(&format!(
3322                "/?flash={}",
3323                qenc("Could not reach your PDS — nothing was renamed or moved.")
3324            ))
3325            .into_response());
3326        }
3327    };
3328    let Some(existing) = existing else {
3329        // The rkey is not in the reader's repo. Renaming a record that is not
3330        // there would CREATE one, which is not what "rename" means and would
3331        // give it a fresh `createdAt` — the bug this read exists to prevent.
3332        warn!(%did, %rkey, "refused rename: no such subscription in the repo");
3333        return Ok(Redirect::to(&format!(
3334            "/?flash={}",
3335            qenc("That subscription is no longer in your repo — nothing was renamed or moved.")
3336        ))
3337        .into_response());
3338    };
3339
3340    // The subscription can be repointed at a different feed URL. **Every gate
3341    // on the URL applies to a repoint and only a repoint** — the three below
3342    // were each, at one time, run before this line on the URL as posted, and
3343    // each refused a pure retitle of a record that already existed:
3344    //
3345    // - privacy: the narrowed at:// arm fails closed as `Private` for an
3346    //   at-URI that is not a publication (a feed generator another client
3347    //   subscribed to), so the record became un-editable with a flash saying
3348    //   it "was not saved or sent anywhere";
3349    // - the global feeds ceiling keyed on "URL not in the cache", and an
3350    //   at:// record is never cached with the flag off, so at capacity a
3351    //   retitle was refused for a row the handler would not insert;
3352    // - storability, the same way.
3353    //
3354    // An unchanged URL is already in the reader's repo; refusing to retitle
3355    // it protects nothing and takes their own record away from them.
3356    // Like for like: the form value is trimmed, and a record another client
3357    // wrote may carry padding — compared raw, every retitle of it was a repoint.
3358    let url_changed = existing.url.trim() != feed_url;
3359
3360    // **Storability, on the same terms as the add and OPML paths — for a
3361    // REPOINT, and FIRST.** A target this instance cannot store gets that
3362    // answer, not "private" (the at:// arm fails closed) or "at capacity"
3363    // (it would never be inserted) — the ordering the add path has. This handler writes `feeds` via `upsert_feed` and had only the
3364    // privacy check above, so `FEATHERREADER_STANDARD_SITE` was bypassable
3365    // here; a review found it by enumerating every writer of the table. The
3366    // first fix ran this check before the repo lookup, on the URL as posted —
3367    // which refused a pure retitle of a subscription that already IS an
3368    // at-URI, on every instance with the flag off. The flag gates what the
3369    // cache may store, not whether a reader may edit their own record: an
3370    // unchanged non-storable URL keeps its PDS write and simply gets no cache
3371    // row below.
3372    let storable = feed::is_storable_feed_url(&feed_url, state.config.standard_site);
3373    if url_changed && !storable {
3374        info!(url = %feed_url, %did, %rkey, "refused a repoint to a non-storable feed URL");
3375        return Ok(
3376            Redirect::to(&format!("/?flash={}", qenc(UNSUPPORTED_FEED_URL_REFUSAL)))
3377                .into_response(),
3378        );
3379    }
3380
3381    // Block private/paid feeds on a repoint. `url` is attacker-controllable,
3382    // and rename both upserts it to the local cache AND rewrites the PDS
3383    // subscription record (a public `putRecord`), so without this guard a
3384    // crafted rename could land a secret-bearing URL in the public PDS — the
3385    // exact leak the add and OPML paths already prevent.
3386    if url_changed {
3387        if let feed::FeedPrivacy::Private(reason) = feed::classify_feed_privacy(&feed_url) {
3388            info!(url = %feed_url, %reason, %did, %rkey, "refused private/paid feed at rename (not stored or written)");
3389            return Ok(
3390                Redirect::to(&format!("/?flash={}", qenc(PRIVATE_FEED_REFUSAL))).into_response(),
3391            );
3392        }
3393    }
3394
3395    // Global feeds ceiling parity with add_subscription: a repoint to a
3396    // brand-new feed URL would insert a NEW `feeds` row. Refuse that when the
3397    // shared cache is at capacity (an existing/duplicate URL adds no row and
3398    // is always fine). `<= 0` disables.
3399    let feeds_cap = state.config.max_feeds_global;
3400    if url_changed
3401        && feeds_cap > 0
3402        && store::get_feed_by_url(&state.db, &feed_url)
3403            .await?
3404            .is_none()
3405    {
3406        match store::count_feeds(&state.db).await {
3407            Ok(n) if n >= feeds_cap => {
3408                warn!(%did, %rkey, feeds = n, cap = feeds_cap, feed = %feed_url, "refused rename: global feeds ceiling reached");
3409                return Ok(Redirect::to(&format!(
3410                    "/?flash={}",
3411                    qenc(
3412                        "This instance is at its feed capacity right now. Please try again later."
3413                    )
3414                ))
3415                .into_response());
3416            }
3417            Ok(_) => {}
3418            Err(err) => warn!(%err, "could not count feeds for global-cap check; allowing"),
3419        }
3420    }
3421
3422    let mut sub = existing;
3423    sub.url = feed_url;
3424    sub.title = form
3425        .title
3426        .map(|t| t.trim().to_string())
3427        .filter(|t| !t.is_empty());
3428    sub.folder = form
3429        .folder
3430        .map(|f| f.trim().to_string())
3431        .filter(|f| !f.is_empty());
3432    // `createdAt` and `private` carry over untouched — neither is a property of
3433    // which feed URL the subscription points at.
3434    //
3435    // `siteUrl` and `fetchHint` ARE properties of the specific feed, so a
3436    // repoint drops them rather than leaving a site link for the old feed
3437    // hanging off the new one. An explicit form value still wins if the form
3438    // ever starts carrying one.
3439    match form
3440        .site_url
3441        .map(|t| t.trim().to_string())
3442        .filter(|t| !t.is_empty())
3443    {
3444        Some(site) => sub.site_url = Some(site),
3445        None if url_changed => sub.site_url = None,
3446        None => {}
3447    }
3448    if url_changed {
3449        sub.fetch_hint = None;
3450    }
3451
3452    // Keep the local cache title in step for the loose-feed fallback path —
3453    // for a row this instance would have. Two cases write nothing:
3454    //
3455    // - not storable (an existing at-URI with the flag off): the record is the
3456    //   reader's to edit, the cache row is not this instance's to create;
3457    // - an unchanged URL with no cache row: a retitle is never the write that
3458    //   CREATES a row. That covers two findings at once — the ceiling is
3459    //   checked on a repoint only, so a retitle must not insert past it; and
3460    //   a secret-bearing URL another client subscribed to has no row (the
3461    //   privacy gate above runs on a repoint only, and `resolve_subscriptions`
3462    //   refuses to cache it), so it cannot enter the shared table here, be
3463    //   polled, fail, and be printed on the admin page. A privacy re-check on
3464    //   this write was the first draft; mutation showed it dead — the row
3465    //   rule already refused every case it would have.
3466    let cache_write =
3467        storable && (url_changed || store::get_feed_by_url(&state.db, &sub.url).await?.is_some());
3468    if !cache_write {
3469        info!(%did, %rkey, url = %sub.url, "renamed a subscription without touching the cache");
3470    } else if let Err(err) = store::upsert_feed(
3471        &state.db,
3472        &store::NewFeed {
3473            url: sub.url.clone(),
3474            title: sub.title.clone(),
3475            site_url: sub.site_url.clone(),
3476            ..Default::default()
3477        },
3478    )
3479    .await
3480    {
3481        // Not fatal to the rename — the PDS record below is the source of truth
3482        // — but a missing `feeds` row means this subscription is never polled.
3483        warn!(%err, %did, url = %sub.url, "could not update the cached feed row on rename");
3484    }
3485
3486    // **The PDS write decides what the reader is told.**
3487    //
3488    // This used to `warn!` on failure and then redirect exactly as it does on
3489    // success, so a rename that did not happen was indistinguishable from one
3490    // that did — the reader saw their old title come back and had no reason to
3491    // think anything had gone wrong. The PDS record IS the subscription; a
3492    // failure here means nothing was renamed or moved.
3493    match state.repo().update_subscription(&did, &rkey, &sub).await {
3494        Ok(res) => {
3495            info!(%did, %rkey, uri = %res.uri, "renamed/moved subscription");
3496            Ok(Redirect::to("/").into_response())
3497        }
3498        Err(err) => {
3499            warn!(%err, %did, %rkey, "PDS subscription update failed");
3500            Ok(Redirect::to(&format!(
3501                "/?flash={}",
3502                qenc("Could not save that change to your PDS — nothing was renamed or moved.")
3503            ))
3504            .into_response())
3505        }
3506    }
3507}
3508
3509// ---------------------------------------------------------------------------
3510// Folders
3511// ---------------------------------------------------------------------------
3512
3513/// Form body for `POST /folders`.
3514#[derive(Debug, Deserialize)]
3515struct FolderForm {
3516    name: String,
3517}
3518
3519/// `POST /folders` — create a folder record.
3520async fn create_folder(
3521    State(state): State<AppState>,
3522    headers: HeaderMap,
3523    Form(form): Form<FolderForm>,
3524) -> Result<Response, WebError> {
3525    let did = match current_did(&state, &headers).await {
3526        Some(d) => d,
3527        None => return Ok(Redirect::to("/login").into_response()),
3528    };
3529    let name = form.name.trim();
3530    if name.is_empty() {
3531        return Ok(Redirect::to("/").into_response());
3532    }
3533    let folder = Folder::new(name.to_string(), now_rfc3339());
3534    match state.repo().add_folder(&did, &folder).await {
3535        Ok(rkey) => info!(%did, %rkey, name, "created folder record"),
3536        Err(err) => warn!(%err, %did, "PDS folder create failed"),
3537    }
3538    Ok(Redirect::to("/").into_response())
3539}
3540
3541/// `POST /folders/:rkey/rename` — rename a folder record.
3542async fn rename_folder(
3543    State(state): State<AppState>,
3544    headers: HeaderMap,
3545    Path(rkey): Path<String>,
3546    Form(form): Form<FolderForm>,
3547) -> Result<Response, WebError> {
3548    let did = match current_did(&state, &headers).await {
3549        Some(d) => d,
3550        None => return Ok(Redirect::to("/login").into_response()),
3551    };
3552    let name = form.name.trim();
3553    if name.is_empty() {
3554        return Ok(Redirect::to("/").into_response());
3555    }
3556    let folder = Folder::new(name.to_string(), now_rfc3339());
3557    match state.repo().rename_folder(&did, &rkey, &folder).await {
3558        Ok(res) => info!(%did, %rkey, uri = %res.uri, "renamed folder"),
3559        Err(err) => warn!(%err, %did, %rkey, "PDS folder rename failed"),
3560    }
3561    Ok(Redirect::to("/").into_response())
3562}
3563
3564/// `POST /folders/:rkey/delete` — delete a folder record (feeds referencing it
3565/// simply become un-foldered).
3566async fn delete_folder(
3567    State(state): State<AppState>,
3568    headers: HeaderMap,
3569    Path(rkey): Path<String>,
3570) -> Result<Response, WebError> {
3571    let did = match current_did(&state, &headers).await {
3572        Some(d) => d,
3573        None => return Ok(Redirect::to("/login").into_response()),
3574    };
3575    match state.repo().remove_folder(&did, &rkey).await {
3576        Ok(()) => info!(%did, %rkey, "deleted folder record"),
3577        Err(err) => warn!(%err, %did, %rkey, "PDS folder delete failed"),
3578    }
3579    Ok(Redirect::to("/").into_response())
3580}
3581
3582/// Resolve a user-pasted URL to a canonical feed URL: if fetching it yields a
3583/// feed document we take it as-is; if it yields an HTML page we run
3584/// autodiscovery over its `<link rel="alternate">` tags.
3585async fn resolve_feed_url(_config: &Config, input: &str) -> anyhow::Result<String> {
3586    let parsed =
3587        url::Url::parse(input).map_err(|e| anyhow::anyhow!("not a valid URL {input:?}: {e}"))?;
3588
3589    let client = feed::build_client()?;
3590    // Fetch through the SSRF guard: scheme + resolved-IP checks on the URL and
3591    // every redirect hop, so a user-pasted URL can't reach cloud metadata /
3592    // loopback / private hosts.
3593    let resp = crate::net::guarded_get(&client, parsed.as_str(), &[]).await?;
3594    let final_url = resp.url().clone();
3595    let content_type = resp
3596        .headers()
3597        .get(axum::http::header::CONTENT_TYPE)
3598        .and_then(|v| v.to_str().ok())
3599        .unwrap_or("")
3600        .to_ascii_lowercase();
3601    // Cap the body (streamed, aborts over 8 MiB) — never trust Content-Length,
3602    // gzip strips it, and this response is reflected into the UI.
3603    let raw = crate::net::read_capped(resp).await?;
3604    let body = String::from_utf8_lossy(&raw).into_owned();
3605
3606    let looks_like_feed = content_type.contains("xml")
3607        || content_type.contains("rss")
3608        || content_type.contains("atom")
3609        || content_type.contains("application/feed+json")
3610        || {
3611            let head = body.trim_start();
3612            head.starts_with("<?xml")
3613                || head.starts_with("<rss")
3614                || head.starts_with("<feed")
3615                || head.contains("<rss")
3616                || head.contains("<feed")
3617        };
3618    if looks_like_feed {
3619        return Ok(final_url.to_string());
3620    }
3621
3622    match feed::discover_feed(&body, Some(&final_url)) {
3623        Some(u) => Ok(u.to_string()),
3624        None => anyhow::bail!("no feed found at {input} (no autodiscovery link)"),
3625    }
3626}
3627
3628// ---------------------------------------------------------------------------
3629// Login (atproto OAuth via the sidecar)
3630// ---------------------------------------------------------------------------
3631
3632/// Query for `GET /login`.
3633#[derive(Debug, Deserialize, Default)]
3634struct LoginQuery {
3635    #[serde(default)]
3636    handle: Option<String>,
3637    #[serde(default)]
3638    error: Option<String>,
3639    #[serde(default)]
3640    flash: Option<String>,
3641}
3642
3643/// `GET /login` — start the atproto OAuth flow, or render the handle form.
3644///
3645/// **Pre-handshake gate:** starting OAuth (a `?handle=` GET) is refused unless
3646/// the visitor is allowed by [`may_start_oauth`] — an existing beta seat (via
3647/// session cookie *or* the submitted handle resolving to a seated DID) or a
3648/// valid reserving invite cookie. Refusal redirects to `/beta/redeem`. The bare
3649/// form (no handle) always renders.
3650async fn login_form(
3651    State(state): State<AppState>,
3652    headers: HeaderMap,
3653    Query(q): Query<LoginQuery>,
3654) -> Response {
3655    if let Some(handle) = q
3656        .handle
3657        .map(|h| h.trim().to_string())
3658        .filter(|h| !h.is_empty())
3659    {
3660        if !may_start_oauth(&state, &headers, &handle).await {
3661            return Redirect::to("/beta/redeem").into_response();
3662        }
3663        return start_oauth(&state, &handle).await;
3664    }
3665    render(&LoginTemplate {
3666        repo_url: REPO_URL,
3667        error: q.error.unwrap_or_default(),
3668        flash: q.flash.unwrap_or_default(),
3669    })
3670}
3671
3672/// `POST /login` — the handle-form submit: redirect into the sidecar OAuth flow.
3673/// Subject to the same pre-handshake invite gate as `GET /login?handle=`.
3674async fn login_submit(
3675    State(state): State<AppState>,
3676    headers: HeaderMap,
3677    Form(form): Form<LoginForm>,
3678) -> Response {
3679    let handle = form.handle.trim();
3680    if handle.is_empty() {
3681        return login_error("Enter your atproto handle.");
3682    }
3683    if !may_start_oauth(&state, &headers, handle).await {
3684        return Redirect::to("/beta/redeem").into_response();
3685    }
3686    start_oauth(&state, handle).await
3687}
3688
3689/// Whether this visitor is allowed to *start* the OAuth handshake. The gate
3690/// admits, in order of cost:
3691///
3692/// 1. an existing beta member's cookie session whose DID already holds a seat;
3693/// 2. a fresh visitor carrying a valid reserving invite cookie;
3694/// 3. a cookie-less visitor whose submitted `handle` resolves to a DID that
3695///    already holds a seat — this honors the **seeded admin's first login** on a
3696///    fresh deploy (and any returning member who cleared cookies) without a
3697///    session cookie or an invite code.
3698///
3699/// The cookie/invite fast paths run FIRST and short-circuit, so the network
3700/// handle→DID resolution is only attempted when neither applies. It fails
3701/// CLOSED: a malformed/unresolvable handle, a resolution error/timeout, or a
3702/// resolved DID with no seat all leave the visitor bounced to `/beta/redeem`.
3703/// This keeps the anti-abuse intent — a rando now pays a cheap handle
3704/// resolution instead of a burned sidecar handshake (and `/login` is already in
3705/// the rate-limited path set).
3706async fn may_start_oauth(state: &AppState, headers: &HeaderMap, handle: &str) -> bool {
3707    // The production resolver is the app's existing atproto handle→DID path,
3708    // routed through the SSRF guard. Resolution is injected so tests can exercise
3709    // the gate without a live network call (the guard forbids loopback mocks).
3710    may_start_oauth_with(state, headers, handle, |h| async move {
3711        crate::atproto::resolve_handle(&state.http, &state.config.resolver_base, &h)
3712            .await
3713            .ok()
3714    })
3715    .await
3716}
3717
3718/// Core of [`may_start_oauth`] with the handle→DID resolver injected as `resolve`
3719/// (returning `Some(did)` on success, `None` on any failure/unresolvable handle).
3720/// The cookie + invite fast paths run FIRST and short-circuit, so `resolve` is
3721/// only called when neither admits — keeping the network round-trip off the hot
3722/// path and preserving the fail-closed contract on resolution failure.
3723async fn may_start_oauth_with<F, Fut>(
3724    state: &AppState,
3725    headers: &HeaderMap,
3726    handle: &str,
3727    resolve: F,
3728) -> bool
3729where
3730    F: FnOnce(String) -> Fut,
3731    Fut: std::future::Future<Output = Option<String>>,
3732{
3733    // 1. An already-beta'd session may re-auth freely.
3734    if let Some(did) = current_did(state, headers).await {
3735        if store::has_beta_access(&state.db, &did)
3736            .await
3737            .unwrap_or(false)
3738        {
3739            return true;
3740        }
3741    }
3742    // 2. A valid reserving invite cookie.
3743    if invite_cookie_code(headers, &state.config.cookie_secret).is_some() {
3744        return true;
3745    }
3746    // 3. Cookie-less: honor an existing seat by resolving the submitted handle to
3747    //    a DID (the seeded-admin first-login / cleared-cookies case). Fail closed
3748    //    on any resolution error or unresolvable/malformed handle.
3749    match resolve(handle.to_string()).await {
3750        Some(did) => store::has_beta_access(&state.db, &did)
3751            .await
3752            .unwrap_or(false),
3753        None => {
3754            warn!(%handle, "handle resolution failed in pre-handshake beta gate");
3755            false
3756        }
3757    }
3758}
3759
3760/// Begin the OAuth handshake for `handle`, on whichever backend is live.
3761///
3762/// **On `form-action 'self'` and this redirect.** The Rust arm answers a form
3763/// POST with a redirect straight to the PDS — cross-origin — while the app's CSP
3764/// carries `form-action 'self'`. Browsers have historically disagreed about
3765/// whether that directive applies to redirects following a form submission, and
3766/// if it did here, login would break in a browser while every test passed.
3767///
3768/// It does not, and the evidence is the SIDECAR path, which is live in
3769/// production today: `POST /login` -> 303 to the same-origin `/oauth/login` ->
3770/// 302 to the PDS, cross-origin, under this same CSP. A browser checking the
3771/// whole redirect chain would already be blocking that. One checking only the
3772/// form's action URL sees `/login` in both cases. The two arms differ only in
3773/// how many same-origin hops precede the cross-origin one, so any policy that
3774/// permits the sidecar flow permits this one.
3775///
3776/// The two arms differ in SHAPE, not just in implementation. The sidecar owns
3777/// its own `/login` and its own callback, so starting a login is one redirect
3778/// and nothing is stored here. The Rust backend pushes the authorization
3779/// request itself, which means this app now holds the pending login — and must
3780/// set the browser-binding cookie that the callback will be checked against.
3781async fn start_oauth(state: &AppState, handle: &str) -> Response {
3782    match state.config.repo_backend {
3783        crate::metrics::Backend::Sidecar => {
3784            let url = state.sidecar.login_url(handle, None);
3785            info!(%handle, "redirecting to OAuth sidecar login");
3786            Redirect::to(&url).into_response()
3787        }
3788        crate::metrics::Backend::Rust => {
3789            let Some(runtime) = state.oauth.as_deref() else {
3790                warn!("the rust backend is live but its OAuth runtime is absent");
3791                return login_error("Login is not available right now.");
3792            };
3793            match crate::oauth::login::start(
3794                runtime,
3795                &state.http,
3796                &state.db,
3797                handle,
3798                crate::store::now_unix(),
3799            )
3800            .await
3801            {
3802                Ok(started) => {
3803                    info!(%handle, "pushed authorization request; redirecting to the PDS");
3804                    let mut resp = Redirect::to(&started.authorize_url).into_response();
3805                    set_cookie(
3806                        &mut resp,
3807                        &cookie::sign_value(
3808                            OAUTH_BINDING_COOKIE,
3809                            &started.binding_token,
3810                            &state.config.cookie_secret,
3811                            OAUTH_BINDING_MAX_AGE_SECS,
3812                        ),
3813                    );
3814                    resp
3815                }
3816                Err(err) => {
3817                    // The handle the user typed is logged; the error is not shown
3818                    // to them verbatim, since it can name internal hosts.
3819                    warn!(%err, %handle, "could not start the OAuth login");
3820                    login_error("Could not start login for that handle.")
3821                }
3822            }
3823        }
3824    }
3825}
3826
3827/// Clear the browser-binding cookie. Called on every terminal outcome of a
3828/// callback, successful or not: the pending row is consumed either way, so a
3829/// lingering cookie can only ever match a login that no longer exists.
3830fn clear_binding_cookie(resp: &mut Response) {
3831    set_cookie(
3832        resp,
3833        &format!("{OAUTH_BINDING_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
3834    );
3835}
3836
3837/// Form body for `POST /login`.
3838#[derive(Debug, Deserialize)]
3839struct LoginForm {
3840    handle: String,
3841}
3842
3843/// Query for `GET /oauth/callback`.
3844///
3845/// Carries BOTH shapes, because the two backends deliver different things to
3846/// the same URL: the sidecar hands back a one-shot `session_id` it has already
3847/// exchanged, while the PDS redirects here directly with `code`/`state`/`iss`
3848/// for this app to exchange itself. Which fields are populated is decided by
3849/// which backend started the login, not by which is live now — so a flip with a
3850/// login already in flight still lands in the right arm.
3851#[derive(Debug, Deserialize, Default)]
3852struct CallbackQuery {
3853    /// Sidecar backend: the handoff id.
3854    #[serde(default)]
3855    session_id: Option<String>,
3856    /// Rust backend: the authorization code and its envelope.
3857    #[serde(default)]
3858    code: Option<String>,
3859    #[serde(default)]
3860    state: Option<String>,
3861    #[serde(default)]
3862    iss: Option<String>,
3863    /// JARM, which is not supported — carried only so it can be refused
3864    /// explicitly rather than read as "no code".
3865    #[serde(default)]
3866    response: Option<String>,
3867    #[serde(default)]
3868    error: Option<String>,
3869    #[serde(default)]
3870    error_description: Option<String>,
3871}
3872
3873/// `GET /oauth/callback` — establish the cookie session.
3874///
3875/// **Invite gate:** the verified DID must hold beta access. If it already does
3876/// (existing member / seeded admin) it's admitted directly. Otherwise we bind
3877/// the DID to the reserved invite cookie: `redeem_code` atomically consumes the
3878/// code and grants the seat. A DID with neither is bounced to `/beta/redeem`.
3879async fn oauth_callback(
3880    State(state): State<AppState>,
3881    headers: HeaderMap,
3882    Query(q): Query<CallbackQuery>,
3883) -> Response {
3884    // An error response is handled by the SAME arm that would have handled a
3885    // success, not short-circuited here.
3886    //
3887    // Returning early looks obviously right and is wrong on the Rust path: it
3888    // skips `verify_callback`, which validates `iss` BEFORE reporting the error
3889    // precisely because RFC 9207 §2.4 says a client "MUST NOT assume that the
3890    // error originates from the intended AS". It also leaves the pending row
3891    // unconsumed, so a `state` that has already produced a callback stays usable
3892    // until it expires.
3893    //
3894    // The sidecar arm has no such check to reach, so it is short-circuited
3895    // below, preserving exactly what it did before.
3896    // **The arm is chosen by what the SERVER knows, not by what the caller
3897    // sent.** A `session_id` in the query used to select the sidecar arm on its
3898    // own — so a caller could pick which code path ran, and the sidecar arm has
3899    // no browser-binding check at all. It also short-circuited the error path
3900    // below, skipping the `iss` validation.
3901    //
3902    // Requiring the Rust runtime to be absent, or a sidecar backend to be the
3903    // configured one, means the selection follows this deployment's own
3904    // configuration. A login started before a flip still completes, because the
3905    // Rust arm is reached whenever the Rust runtime exists and can match the
3906    // `state` against a pending row it actually wrote.
3907    // The sidecar hands off in TWO shapes, not one: `?session_id=…` on success
3908    // and `?error=…&error_description=…` on its own failure. Keying only on
3909    // `session_id` sent the failure shape down the Rust arm, which then failed
3910    // with "no `state`" and replaced the specific reason with a generic one —
3911    // and `error_description` is exactly what the sidecar Caddy routing matches
3912    // to send that request here in the first place.
3913    let sidecar_shape =
3914        q.session_id.as_deref().is_some_and(|s| !s.is_empty()) || q.error_description.is_some();
3915    let sidecar_handoff = sidecar_shape
3916        && (state.oauth.is_none() || state.config.repo_backend == crate::metrics::Backend::Sidecar);
3917    if let Some(err) = q.error.clone() {
3918        // **Neither the code nor the description is echoed as sent.**
3919        //
3920        // Both are server-controlled free text arriving on a public GET, so
3921        // anyone who can make a browser fetch this URL chooses them. The raw
3922        // `error` used to go into a `warn!` AND into the rendered login page,
3923        // and `error_description` — arbitrary text, newlines included — went
3924        // into the log verbatim: a log-injection surface on one side and
3925        // attacker-chosen copy in the product's own voice on the other.
3926        //
3927        // `oauth::flow` already decided this exact question for the Rust arm:
3928        // reduce the code to a known slug, drop the description entirely. That
3929        // reasoning is not specific to which arm handles the callback, and this
3930        // one simply never got the same treatment. The description's LENGTH is
3931        // kept, because "the server sent a 4 KB explanation" is occasionally
3932        // worth knowing and cannot be used to inject anything.
3933        let slug = crate::oauth::flow::known_error_slug(&err);
3934        warn!(
3935            error = slug,
3936            desc_len = q.error_description.as_deref().map_or(0, str::len),
3937            "OAuth callback returned an error"
3938        );
3939        if sidecar_handoff || state.oauth.is_none() {
3940            return login_error(&format!("Login failed: {slug}"));
3941        }
3942        // Fall through: the Rust arm consumes the pending row and validates
3943        // `iss` against it, and reports the failure afterwards.
3944    }
3945
3946    // Which arm runs is decided by WHAT ARRIVED, not by which backend is
3947    // currently selected: a login started before a flip must still complete.
3948    let session = if sidecar_handoff {
3949        let session_id = q.session_id.clone().unwrap_or_default();
3950        match state.sidecar.resolve_session(&session_id).await {
3951            Ok(Some(s)) => s,
3952            Ok(None) => {
3953                warn!("OAuth callback session_id did not resolve (expired/unknown)");
3954                return login_error("Login session expired — please try again.");
3955            }
3956            Err(err) => {
3957                warn!(%err, "failed to resolve OAuth session via the sidecar");
3958                return login_error("Login failed talking to the auth service.");
3959            }
3960        }
3961    } else {
3962        let Some(runtime) = state.oauth.as_deref() else {
3963            warn!("an OAuth callback arrived with no sidecar session and no Rust runtime");
3964            return login_error("Login failed: this login could not be completed.");
3965        };
3966        let params = crate::oauth::flow::CallbackParams {
3967            code: q.code.clone(),
3968            state: q.state.clone(),
3969            iss: q.iss.clone(),
3970            // Passed through, NOT dropped: `verify_callback` checks `iss`
3971            // against the pending row's issuer before it reports the error, and
3972            // it cannot do that for an error it never sees.
3973            error: q.error.clone(),
3974            error_description: q.error_description.clone(),
3975            response: q.response.clone(),
3976        };
3977        let binding =
3978            cookie::verify_value(&headers, OAUTH_BINDING_COOKIE, &state.config.cookie_secret);
3979        match crate::oauth::login::complete(
3980            runtime,
3981            &state.http,
3982            &state.db,
3983            &params,
3984            binding.as_deref(),
3985            crate::store::now_unix(),
3986        )
3987        .await
3988        {
3989            Ok(done) => crate::atproto::SidecarSession {
3990                did: done.did,
3991                handle: done.handle,
3992            },
3993            Err(err) => {
3994                // Never echoed to the browser: the message can name the issuer,
3995                // the PDS, and why a binding check failed.
3996                warn!(%err, "could not complete the OAuth callback");
3997                let mut resp = login_error("Login failed — please try again.");
3998                clear_binding_cookie(&mut resp);
3999                return resp;
4000            }
4001        }
4002    };
4003
4004    // Bind the verified DID to the invite gate. Returns a response only on the
4005    // (rare) failure paths; `Ok(())` means the DID now holds beta access.
4006    let mut clear_invite = false;
4007    if !store::has_beta_access(&state.db, &session.did)
4008        .await
4009        .unwrap_or(false)
4010    {
4011        // Not yet a member: consume the reserved invite code, if any.
4012        let code = match invite_cookie_code(&headers, &state.config.cookie_secret) {
4013            Some(c) => c,
4014            None => {
4015                warn!(did = %session.did, "OAuth callback with no beta access and no invite cookie");
4016                return Redirect::to("/beta/redeem").into_response();
4017            }
4018        };
4019        match store::redeem_code(
4020            &state.db,
4021            &code,
4022            &session.did,
4023            session.handle.as_deref(),
4024            state.config.beta_cap,
4025        )
4026        .await
4027        {
4028            Ok(Ok(())) => {
4029                clear_invite = true;
4030                info!(did = %session.did, "invite code redeemed at OAuth callback; beta access granted");
4031            }
4032            Ok(Err(policy)) => {
4033                warn!(did = %session.did, ?policy, "invite redeem failed at callback");
4034                let mut resp = redeem_bounce(&policy).into_response();
4035                // The reservation is spent/invalid — drop the stale invite cookie.
4036                clear_invite_cookie(&mut resp);
4037                return resp;
4038            }
4039            Err(err) => {
4040                warn!(%err, did = %session.did, "invite redeem infra error at callback");
4041                return login_error("Login failed while confirming your invite.");
4042            }
4043        }
4044    }
4045
4046    // Mint an opaque, random server-side session id and store the identity under
4047    // it; the cookie carries the (HMAC-signed) sid, never the DID.
4048    let sid = state.sessions.create(Session {
4049        did: session.did.clone(),
4050        handle: session.handle.clone(),
4051    });
4052    let cookie = cookie::sign_session(&sid, &state.config.cookie_secret);
4053    info!(did = %session.did, handle = ?session.handle, "OAuth login OK; session cookie set");
4054
4055    let mut resp = Redirect::to("/").into_response();
4056    set_cookie(&mut resp, &cookie);
4057    clear_binding_cookie(&mut resp);
4058    if clear_invite {
4059        clear_invite_cookie(&mut resp);
4060    }
4061    resp
4062}
4063
4064/// Revoke a DID's OAuth session on BOTH backends, best-effort.
4065///
4066/// Not "whichever backend is live": during a cutover a user's tokens can be in
4067/// either store — they logged in under one backend and are logging out under
4068/// the other. Revoking only the live one would leave a live refresh token
4069/// behind in the other, which is the exact failure sign-out exists to prevent,
4070/// and it would be invisible because the sign-out itself looks successful.
4071///
4072/// Both arms are best-effort. The caller has already decided to sign the user
4073/// out, and a network failure must not trap them in a half-logged-out state.
4074/// How long sign-out will wait for a final read-state flush before revoking
4075/// anyway.
4076///
4077/// Bounded because the flush talks to the user's PDS, and a user trying to leave
4078/// must never be held by a server that is not answering. Three seconds is long
4079/// enough for a healthy `applyWrites` (the production samples run 100–970 ms)
4080/// and short enough that a dead PDS is an inconvenience rather than a trap.
4081const SIGN_OUT_FLUSH_BUDGET: std::time::Duration = std::time::Duration::from_secs(3);
4082
4083/// Flush whatever read-state is still dirty for `did`, then give up quietly.
4084///
4085/// **Called before revoking, because revoking first strands it (#117).**
4086/// `revoke_everywhere` deletes the OAuth session, and a dirty cursor with no
4087/// session cannot be sent by anyone — it parks until the user signs in again,
4088/// which may be never. Flushing first is what stops the common case from
4089/// becoming that.
4090///
4091/// Best-effort by construction: every failure path here falls through to the
4092/// revoke. A flush that times out or errors leaves the cursors dirty, which is
4093/// the parked state the flusher now handles deliberately rather than retrying
4094/// forever.
4095async fn flush_before_revoke(state: &AppState, did: &str) {
4096    match tokio::time::timeout(
4097        SIGN_OUT_FLUSH_BUDGET,
4098        crate::readstate::flush_did(state, did),
4099    )
4100    .await
4101    {
4102        Ok(Ok(())) => {}
4103        Ok(Err(err)) => {
4104            warn!(%did, %err, "sign-out: final read-state flush failed; it will park until next sign-in")
4105        }
4106        Err(_) => warn!(
4107            %did,
4108            budget = ?SIGN_OUT_FLUSH_BUDGET,
4109            "sign-out: final read-state flush timed out; it will park until next sign-in"
4110        ),
4111    }
4112}
4113
4114async fn revoke_everywhere(state: &AppState, did: &str) {
4115    // **Counted under Backend::Sidecar, not left uncounted.** A review found
4116    // that recording only the rust arm let `oauth_revoke` report a clean success
4117    // while every sidecar revocation failed — and for anyone who logged in before
4118    // the cutover, the sidecar store is the ONLY one that held tokens, so the
4119    // rust arm correctly returns NoSession and the metric reads all-clear while
4120    // live refresh tokens sit at the PDS.
4121    //
4122    // Same op name, different backend: the backend column is what distinguishes
4123    // them, so "no revocation failures" means checking both rows, not one.
4124    let sidecar_started = std::time::Instant::now();
4125    let sidecar_ok = match state.sidecar.revoke_session(did).await {
4126        Ok(res) => {
4127            info!(%did, revoked = res.revoked, "sidecar session revoked");
4128            true
4129        }
4130        Err(err) => {
4131            warn!(%did, %err, "sidecar revoke failed; continuing");
4132            false
4133        }
4134    };
4135    state.metrics.record(
4136        crate::metrics::Backend::Sidecar,
4137        "oauth_revoke",
4138        sidecar_started.elapsed().as_micros() as u64,
4139        sidecar_ok,
4140    );
4141
4142    if let Some(runtime) = state.oauth.as_deref() {
4143        let revoke_started = std::time::Instant::now();
4144        let outcome = crate::oauth::revoke::sign_out_discovering(
4145            runtime,
4146            &state.http,
4147            &state.db,
4148            did,
4149            crate::store::now_unix(),
4150        )
4151        .await;
4152        // **Counted, because a warn! nobody reads is not observability.** Until
4153        // this existed, a revocation failure left exactly one trace: a log line.
4154        // "No revocation failures this week" was therefore a statement about
4155        // nobody having looked, which is not the same claim.
4156        //
4157        // NoSession counts as a SUCCESS, deliberately. Logout is idempotent —
4158        // there being nothing to revoke is the correct outcome, not a failure,
4159        // and counting it as an error would make the metric noisy in exactly
4160        // the case that is fine. Only `Failed` means the PDS still holds live
4161        // tokens we asked it to drop.
4162        let revoke_ok = !matches!(outcome, crate::oauth::revoke::Revocation::Failed(_));
4163        state.metrics.record(
4164            crate::metrics::Backend::Rust,
4165            "oauth_revoke",
4166            revoke_started.elapsed().as_micros() as u64,
4167            revoke_ok,
4168        );
4169        match outcome {
4170            crate::oauth::revoke::Revocation::Revoked => {
4171                info!(%did, "rust OAuth session revoked at the PDS")
4172            }
4173            crate::oauth::revoke::Revocation::NoSession => {}
4174            crate::oauth::revoke::Revocation::Failed(reason) => {
4175                warn!(%did, %reason, "rust OAuth revoke failed; the local session is gone regardless")
4176            }
4177        }
4178    }
4179}
4180
4181/// `POST /logout` — end the session everywhere, not just in this browser.
4182///
4183/// Clearing the cookie only stops *this* device from presenting the session;
4184/// the sidecar still holds live OAuth tokens for the DID. So logout now also
4185/// calls the sidecar `POST /internal/revoke {did}`, which revokes the refresh +
4186/// access tokens at the PDS and drops the sidecar's session rows. The local
4187/// registry entry is dropped and the cookie cleared regardless of whether the
4188/// revoke round-trip succeeds (best-effort — a network blip must not trap the
4189/// user in a half-logged-out state).
4190async fn logout(State(state): State<AppState>, headers: HeaderMap) -> Response {
4191    if let Some(user) = current_session(&state, &headers).await {
4192        // Only a real cookie session (`sid` present) has sidecar-held tokens to
4193        // revoke; the dev-DID fallback never handshook the sidecar.
4194        if let Some(sid) = user.sid {
4195            state.sessions.remove(&sid);
4196            // BEFORE the revoke: afterwards there is no session to send it with.
4197            flush_before_revoke(&state, &user.did).await;
4198            revoke_everywhere(&state, &user.did).await;
4199        }
4200    }
4201    let mut resp = Redirect::to("/login").into_response();
4202    set_cookie(
4203        &mut resp,
4204        &format!("{SESSION_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
4205    );
4206    resp
4207}
4208
4209/// Form body for `POST /account/delete` — the confirm-gate. The user must type
4210/// `DELETE` into this field for the purge to run.
4211#[derive(Debug, Deserialize)]
4212struct DeleteAccountForm {
4213    #[serde(default)]
4214    confirm: String,
4215}
4216
4217/// The literal a user must type to confirm the destructive delete.
4218const DELETE_CONFIRM_PHRASE: &str = "DELETE";
4219
4220/// `POST /account/delete` (authed) — the "delete my data" endpoint.
4221///
4222/// Confirm-gated: the form must carry `confirm=DELETE` or we bounce back to
4223/// `/manage` with an explanatory flash and touch nothing. On confirmation it:
4224///   1. purges **every** local row owned by the caller DID (`entry_state`,
4225///      `read_cursor`, `sub_ref`, `beta_access` seat, and any invite codes the
4226///      DID created) via [`store::purge_did_data`], then
4227///   2. calls the sidecar `POST /internal/revoke {did}` so the OAuth tokens are
4228///      revoked at the PDS and the sidecar's session rows are dropped, then
4229///   3. drops the in-memory session and clears the cookie, signing the user out.
4230///
4231/// The subscription/folder/saved *records* in the user's own PDS are
4232/// intentionally left alone — they are the user's data on their own server; the
4233/// `/about` copy and this page's UI both say so, and export stays available.
4234async fn account_delete(
4235    State(state): State<AppState>,
4236    headers: HeaderMap,
4237    Form(form): Form<DeleteAccountForm>,
4238) -> Result<Response, WebError> {
4239    let user = match current_session(&state, &headers).await {
4240        Some(u) => u,
4241        None => return Ok(Redirect::to("/login").into_response()),
4242    };
4243    let did = user.did.clone();
4244
4245    // Confirm-gate: require the exact typed phrase before doing anything.
4246    if form.confirm.trim() != DELETE_CONFIRM_PHRASE {
4247        return Ok(Redirect::to(&format!(
4248            "/manage?flash={}",
4249            qenc("Type DELETE to confirm — nothing was deleted.")
4250        ))
4251        .into_response());
4252    }
4253
4254    // 1. Purge every local row this DID owns (single transaction).
4255    let counts = store::purge_did_data(&state.db, &did).await?;
4256    info!(
4257        %did,
4258        total = counts.total(),
4259        entry_state = counts.entry_state,
4260        read_cursor = counts.read_cursor,
4261        sub_ref = counts.sub_ref,
4262        beta_access = counts.beta_access,
4263        invite_codes = counts.invite_codes,
4264        "account/delete: local rows purged"
4265    );
4266
4267    // 2. Revoke the OAuth session at the sidecar/PDS (best-effort — the local
4268    //    rows are already gone; a network blip must not block the sign-out).
4269    revoke_everywhere(&state, &did).await;
4270
4271    // 3. Drop the in-memory session and clear the cookie: sign the user out.
4272    if let Some(sid) = user.sid {
4273        state.sessions.remove(&sid);
4274    }
4275    let mut resp = Redirect::to(&format!(
4276        "/login?flash={}",
4277        qenc("Your data was deleted and you've been signed out. Thanks for trying FeatherReader.")
4278    ))
4279    .into_response();
4280    set_cookie(
4281        &mut resp,
4282        &format!("{SESSION_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
4283    );
4284    Ok(resp)
4285}
4286
4287/// Re-render the login form with an error banner.
4288fn login_error(msg: &str) -> Response {
4289    render(&LoginTemplate {
4290        repo_url: REPO_URL,
4291        error: msg.to_string(),
4292        flash: String::new(),
4293    })
4294}
4295
4296// ---------------------------------------------------------------------------
4297// Closed-beta invite gate (self-serve redeem + admin mint)
4298// ---------------------------------------------------------------------------
4299
4300/// Form body for `POST /beta/redeem`.
4301#[derive(Debug, Deserialize)]
4302struct RedeemForm {
4303    code: String,
4304}
4305
4306/// `GET /beta/redeem` — render the invite-redeem page. If the seat cap is
4307/// already full we render the "capacity full" variant (no form).
4308async fn beta_redeem_form(State(state): State<AppState>) -> Response {
4309    let full = store::count_beta_access(&state.db)
4310        .await
4311        .map(|n| n >= state.config.beta_cap)
4312        .unwrap_or(false);
4313    render(&BetaRedeemTemplate {
4314        repo_url: REPO_URL,
4315        error: String::new(),
4316        capacity_full: full,
4317    })
4318}
4319
4320/// `POST /beta/redeem` — the **pre-handshake** reservation.
4321///
4322/// Validates the pasted code is *redeemable right now* (exists, active,
4323/// unexpired, and a seat is free) WITHOUT consuming it or binding a DID — the
4324/// visitor has no DID yet. On success it sets a short-lived signed invite cookie
4325/// reserving intent to redeem this code, then sends the visitor to `/login`. The
4326/// OAuth callback later binds the verified DID and atomically consumes the code
4327/// (`store::redeem_code`). This ordering means a non-invited visitor can never
4328/// start OAuth (and burn a sidecar handshake).
4329async fn beta_redeem_submit(
4330    State(state): State<AppState>,
4331    Form(form): Form<RedeemForm>,
4332) -> Response {
4333    let code = form.code.trim().to_uppercase();
4334    if code.is_empty() {
4335        return render(&BetaRedeemTemplate {
4336            repo_url: REPO_URL,
4337            error: "Enter your invite code.".to_string(),
4338            capacity_full: false,
4339        });
4340    }
4341
4342    match preflight_code(&state, &code).await {
4343        Ok(()) => {
4344            let cookie = sign_invite(&code, &state.config.cookie_secret);
4345            let mut resp = Redirect::to("/login").into_response();
4346            set_cookie(&mut resp, &cookie);
4347            info!("invite code preflight OK; reserving intent + redirecting to /login");
4348            resp
4349        }
4350        Err(policy) => {
4351            warn!(?policy, "invite code preflight rejected");
4352            redeem_bounce(&policy)
4353        }
4354    }
4355}
4356
4357/// Read-only preflight of an invite code for the pre-handshake reservation:
4358/// verify it exists, is active, is not past `expires_at`, and that a seat is
4359/// free — mirroring the checks `store::redeem_code` will re-run atomically at
4360/// callback time. Does NOT consume the code or grant a seat. Returns the same
4361/// typed [`store::RedeemError`] variants so the two paths share one message map.
4362async fn preflight_code(state: &AppState, code: &str) -> Result<(), store::RedeemError> {
4363    // Cap check first: a clear "capacity full" beats "code invalid" when both.
4364    // FAIL CLOSED on a count error — an `unwrap_or(0)` would let a DB blip read as
4365    // "0 seats used" and wave the redeem through the preflight. (`redeem_code`
4366    // still backstops the real cap inside its tx, so this is a consistency /
4367    // defence-in-depth fix, not the only guard.) Treat an unverifiable count as
4368    // capacity-full: the redeemer sees "at capacity, try later" rather than a mint
4369    // that might overrun the cap.
4370    let count = match store::count_beta_access(&state.db).await {
4371        Ok(n) => n,
4372        Err(err) => {
4373            warn!(%err, "preflight_code: count_beta_access failed; failing closed");
4374            return Err(store::RedeemError::CapacityFull);
4375        }
4376    };
4377    if count >= state.config.beta_cap {
4378        return Err(store::RedeemError::CapacityFull);
4379    }
4380    // Look up the code's current status + expiry (read-only).
4381    let row = sqlx::query_as::<_, (String, i64)>(
4382        "SELECT status, expires_at FROM invite_codes WHERE code = ?1",
4383    )
4384    .bind(code)
4385    .fetch_optional(&state.db)
4386    .await
4387    .ok()
4388    .flatten();
4389    let (status, expires_at) = match row {
4390        Some(r) => r,
4391        None => return Err(store::RedeemError::NotFound),
4392    };
4393    let now = chrono::Utc::now().timestamp();
4394    match status.as_str() {
4395        "active" if expires_at >= now => Ok(()),
4396        "active" => Err(store::RedeemError::Expired),
4397        "expired" => Err(store::RedeemError::Expired),
4398        // "redeemed" or anything else non-active.
4399        _ => Err(store::RedeemError::AlreadyRedeemed),
4400    }
4401}
4402
4403/// Map a [`store::RedeemError`] to the invite page with the right message. Used
4404/// by both the preflight (`POST /beta/redeem`) and the callback bind path.
4405fn redeem_bounce(policy: &store::RedeemError) -> Response {
4406    use store::RedeemError::*;
4407    let (msg, capacity_full) = match policy {
4408        NotFound => ("That invite code isn't valid.", false),
4409        Expired => ("That invite code has expired.", false),
4410        AlreadyRedeemed => ("That invite code has already been used.", false),
4411        CapacityFull => ("", true),
4412    };
4413    render(&BetaRedeemTemplate {
4414        repo_url: REPO_URL,
4415        error: msg.to_string(),
4416        capacity_full,
4417    })
4418}
4419
4420/// Query for `POST /admin/invites` — how many codes to mint (`?n=`, default 1).
4421#[derive(Debug, Deserialize, Default)]
4422struct MintQuery {
4423    #[serde(default)]
4424    n: Option<u32>,
4425}
4426
4427/// `POST /admin/invites?n=N` — mint N invite codes.
4428///
4429/// `GET /oauth/client-metadata.json` — the client's published identity.
4430///
4431/// **This URL IS the `client_id`.** The PDS fetches it during every login and
4432/// caches it against every existing grant, so it must keep answering at exactly
4433/// this path across the cutover — the sidecar serves the same document at the
4434/// same URL today, proxied by the edge.
4435///
4436/// Served whatever backend is live: a request that arrives here is from a PDS
4437/// resolving our identity, and it has no idea which of our two implementations
4438/// is currently answering repo calls.
4439async fn oauth_client_metadata(State(state): State<AppState>) -> Response {
4440    let Some(runtime) = state.oauth.as_deref() else {
4441        // The sidecar is serving this path in front of us, or nothing is.
4442        return (StatusCode::NOT_FOUND, "no client metadata\n").into_response();
4443    };
4444    axum::Json(crate::oauth::metadata::client_metadata(&runtime.client)).into_response()
4445}
4446
4447/// `GET /oauth/jwks.json` — the client's public signing key.
4448///
4449/// Production only. The localhost dev client is a PUBLIC client: it registers no
4450/// key and signs no assertions, so publishing a JWKS there would advertise a
4451/// credential that is never used — and would make a dev deployment look like a
4452/// confidential client to anyone reading it.
4453async fn oauth_jwks(State(state): State<AppState>) -> Response {
4454    let Some(runtime) = state.oauth.as_deref() else {
4455        return (StatusCode::NOT_FOUND, "no jwks\n").into_response();
4456    };
4457    match runtime.client_key.as_ref() {
4458        Some(key) => match key.jwks_document() {
4459            Ok(doc) => axum::Json(doc).into_response(),
4460            Err(err) => {
4461                warn!(%err, "could not render the client JWKS");
4462                (StatusCode::INTERNAL_SERVER_ERROR, "jwks unavailable\n").into_response()
4463            }
4464        },
4465        None => (StatusCode::NOT_FOUND, "this client publishes no jwks\n").into_response(),
4466    }
4467}
4468
4469/// How many failing feeds `/admin/metrics` will name. One response, so bounded.
4470const ADMIN_FAILING_FEED_LIMIT: i64 = 200;
4471
4472/// `GET /admin/metrics` — repo-op latency for both backends, as plain text.
4473///
4474/// Admin-gated on the same rule as the invite minter: the table names every
4475/// operation the reader performs and how often each fails, which is an
4476/// operational picture rather than public information.
4477///
4478/// Text, not JSON or HTML: it is read by a person deciding whether the cutover
4479/// is safe, and the comparison is two rows side by side.
4480async fn admin_metrics(State(state): State<AppState>, headers: HeaderMap) -> Response {
4481    let did = match current_did(&state, &headers).await {
4482        Some(d) => d,
4483        None => return (StatusCode::UNAUTHORIZED, "sign in first\n").into_response(),
4484    };
4485    if !state.config.admin_seed_dids().iter().any(|d| d == &did) {
4486        warn!(%did, "admin metrics denied: not an admin-seed DID");
4487        return (StatusCode::FORBIDDEN, "not an admin\n").into_response();
4488    }
4489
4490    // Flush first, so the table includes this process's traffic up to now.
4491    // Then read the PERSISTED rows, which is the only place both backends can
4492    // appear at once -- a flip is a restart, and in-process memory only ever
4493    // holds the backend currently running.
4494    if let Err(err) =
4495        crate::metrics::flush(&state.metrics, &state.db, crate::store::now_unix()).await
4496    {
4497        warn!(%err, "could not flush repo timings before rendering");
4498    }
4499    let rows = match crate::metrics::persisted_rows(&state.db).await {
4500        Ok(rows) => rows,
4501        Err(err) => {
4502            warn!(%err, "could not read persisted repo timings");
4503            return (StatusCode::INTERNAL_SERVER_ERROR, "metrics unavailable\n").into_response();
4504        }
4505    };
4506
4507    // The live backend is named at the top: a table of two populated rows is
4508    // ambiguous about which one is currently serving users.
4509    // Parked read-state, alongside the timings. The flusher no longer logs
4510    // these every round (#117), so without a number here the state would be
4511    // silent — which is the failure the noisy loop at least did not have.
4512    let parked = match crate::store::parked_readstate_dids(&state.db).await {
4513        Ok(n) => n.to_string(),
4514        Err(err) => {
4515            warn!(%err, "could not count parked read-state DIDs");
4516            "unknown".to_string()
4517        }
4518    };
4519    // **The half the public histogram cannot carry.** `/stats` reports counts by
4520    // cause and nothing else, deliberately — but `fetch` covers DNS failure,
4521    // timeout, SSRF refusal AND this reader's own bugs, so the count alone
4522    // cannot separate "the publishers are gone" from "we are broken". #159 was
4523    // the latter and took a production investigation to establish. Named feeds
4524    // and their error text belong here, behind ALLOWED_DIDS.
4525    let failing = match crate::store::failing_feeds(&state.db, ADMIN_FAILING_FEED_LIMIT).await {
4526        Ok(f) => f,
4527        Err(err) => {
4528            warn!(%err, "could not list failing feeds");
4529            Vec::new()
4530        }
4531    };
4532    let mut failing_block = String::new();
4533    if !failing.is_empty() {
4534        failing_block.push_str("\nfailing feeds (worst first)\n");
4535        for f in &failing {
4536            failing_block.push_str(&format!(
4537                "  {:>4}x  {:<8}  {}\n          {}\n",
4538                f.consecutive_errors,
4539                f.kind.as_deref().unwrap_or("unknown"),
4540                f.url,
4541                f.detail.as_deref().unwrap_or("(no detail recorded)"),
4542            ));
4543        }
4544    }
4545
4546    // **Capacity that no other page can show.** The global ceiling counts every
4547    // row (`store::count_feeds`), but `/stats` measures the poller and excludes
4548    // unpollable ones — so an instance can be at its cap with every public
4549    // number saying otherwise. A review found exactly that gap.
4550    let unpollable = match crate::store::unpollable_feeds(&state.db).await {
4551        Ok(n) => n,
4552        Err(err) => {
4553            warn!(%err, "could not count unpollable feeds");
4554            -1
4555        }
4556    };
4557    let cached = crate::store::count_feeds(&state.db).await.unwrap_or(-1);
4558
4559    let body = format!(
4560        "live backend: {}\nparked read-state DIDs: {}\n\
4561         feeds cached: {} (ceiling {}), of which unpollable: {}\n\n{}{}",
4562        state.config.repo_backend.as_str(),
4563        parked,
4564        cached,
4565        state.config.max_feeds_global,
4566        unpollable,
4567        crate::metrics::render(&rows),
4568        failing_block,
4569    );
4570    (StatusCode::OK, body).into_response()
4571}
4572
4573/// Authorized ONLY for a live session whose DID is in the `ALLOWED_DIDS` admin
4574/// seed (`config.admin_seed_dids`). Returns the freshly-minted codes as
4575/// newline-separated `text/plain`. Deliberately minimal (no HTML UI).
4576async fn admin_mint_invites(
4577    State(state): State<AppState>,
4578    headers: HeaderMap,
4579    Query(q): Query<MintQuery>,
4580) -> Response {
4581    // Require a real, current session (not just a DID string) whose DID is an
4582    // admin-seed DID. `current_did` already re-checks the beta gate.
4583    let did = match current_did(&state, &headers).await {
4584        Some(d) => d,
4585        None => return (StatusCode::UNAUTHORIZED, "sign in first\n").into_response(),
4586    };
4587    if !state.config.admin_seed_dids().iter().any(|d| d == &did) {
4588        warn!(%did, "admin mint denied: not an admin-seed DID");
4589        return (StatusCode::FORBIDDEN, "not an admin\n").into_response();
4590    }
4591
4592    let n = q.n.unwrap_or(1).clamp(1, 100);
4593    let mut codes = Vec::with_capacity(n as usize);
4594    for _ in 0..n {
4595        match store::mint_code(&state.db, &did, INVITE_TTL_SECS).await {
4596            Ok(code) => codes.push(code),
4597            Err(err) => {
4598                warn!(%err, %did, "admin mint_code failed");
4599                return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
4600            }
4601        }
4602    }
4603    info!(%did, count = codes.len(), "admin minted invite codes");
4604    let mut body = codes.join("\n");
4605    body.push('\n');
4606    (StatusCode::OK, body).into_response()
4607}
4608
4609// ---------------------------------------------------------------------------
4610// Bot claim link: /claim?t=<token>  +  POST /bot/claims (shared-secret mint)
4611// ---------------------------------------------------------------------------
4612
4613/// Query for `GET /claim`.
4614#[derive(Debug, Deserialize)]
4615struct ClaimQuery {
4616    /// The opaque claim token from the bot's public follow-back skeet.
4617    t: Option<String>,
4618}
4619
4620/// `GET /claim?t=<token>` — redeem a bot-issued claim link.
4621///
4622/// The follow→invite bot posts a public skeet mentioning a new follower with a
4623/// link here. The token wraps a pre-minted invite code (never the raw code — see
4624/// [`sign_claim_token`]). On a valid, still-redeemable token this behaves exactly
4625/// like a successful `POST /beta/redeem`: it sets the reserving `fr_invite`
4626/// cookie and sends the visitor to `/login`, so they flow through OAuth and the
4627/// callback atomically consumes the code (`store::redeem_code`) — the same
4628/// machinery as a pasted code. On any failure it bounces to the invite page with
4629/// the matching message.
4630///
4631/// Single-use / grabbability: a token in a public URL is grabbable. The code it
4632/// wraps is single-use (redeem flips `active→redeemed`), and `preflight_code`
4633/// here rejects an already-used / expired / capacity-full code before reserving,
4634/// so a replayed link past the first successful claim is refused. The residual
4635/// window is the same as any pasted invite code: whoever completes OAuth *first*
4636/// with a live reservation wins the seat. The per-IP rate limit on `/claim`
4637/// blunts brute-force enumeration.
4638async fn claim(State(state): State<AppState>, Query(q): Query<ClaimQuery>) -> Response {
4639    let token = match q.t {
4640        Some(t) if !t.is_empty() => t,
4641        _ => {
4642            warn!("claim link with no token");
4643            return redeem_bounce(&store::RedeemError::NotFound);
4644        }
4645    };
4646
4647    // Unwrap the token → the invite code it reserves. A tampered/forged token
4648    // yields nothing → treat as an invalid code (don't leak whether it parsed).
4649    let code = match claim_token_code(&token, &state.config.cookie_secret) {
4650        Some(c) => c,
4651        None => {
4652            warn!("claim token invalid (bad signature / malformed)");
4653            return redeem_bounce(&store::RedeemError::NotFound);
4654        }
4655    };
4656
4657    // Re-run the same preflight as the pasted-code path: exists, active,
4658    // unexpired, seat free. This is what makes a replayed link past first-claim
4659    // (or past cap) fail cleanly.
4660    match preflight_code(&state, &code).await {
4661        Ok(()) => {
4662            let cookie = sign_invite(&code, &state.config.cookie_secret);
4663            let mut resp = Redirect::to("/login").into_response();
4664            set_cookie(&mut resp, &cookie);
4665            info!("claim token preflight OK; reserving intent + redirecting to /login");
4666            resp
4667        }
4668        Err(policy) => {
4669            warn!(?policy, "claim token preflight rejected");
4670            redeem_bounce(&policy)
4671        }
4672    }
4673}
4674
4675/// The JSON body `POST /bot/claims` accepts — the follower the claim is FOR.
4676///
4677/// Passing the follower DID makes the APP the authoritative deduper: the app can
4678/// short-circuit a DID that already holds a seat, and return the SAME code for a
4679/// DID that already has an outstanding claim — so a bot-host state loss cannot
4680/// re-mint or re-post per follower. Handle is advisory (logs only).
4681#[derive(Debug, Default, Deserialize)]
4682struct BotClaimRequest {
4683    /// The follower's DID (the idempotency key). Optional for backward-compat: an
4684    /// omitted DID falls back to the old un-keyed mint (no server-side dedupe).
4685    #[serde(default)]
4686    did: Option<String>,
4687    /// The follower's handle (advisory; recorded for operator logs only).
4688    #[serde(default)]
4689    #[allow(dead_code)]
4690    handle: Option<String>,
4691}
4692
4693/// The JSON body `POST /bot/claims` returns on success.
4694#[derive(Debug, serde::Serialize)]
4695struct BotClaimResponse {
4696    /// Server-side dedupe outcome, so the bot knows whether to post:
4697    /// `"minted"` (a fresh code — post the claim link), `"existing"` (this DID
4698    /// already had an outstanding claim; the SAME code/token/url is returned, so an
4699    /// idempotent re-post is safe), or `"already_seated"` (this DID already holds
4700    /// beta access; code/token/url are empty and the bot should post NOTHING).
4701    status: &'static str,
4702    /// The bare invite code (`FEATHER-…`) — for the bot's own logs/idempotency
4703    /// store. NEVER post this publicly; post the `url` instead. Empty when
4704    /// `already_seated`.
4705    code: String,
4706    /// The opaque claim token (the code wrapped + signed). Empty when
4707    /// `already_seated`.
4708    token: String,
4709    /// The full claim URL to put in the public skeet: `${public_url}/claim?t=…`.
4710    /// Empty when `already_seated`.
4711    url: String,
4712}
4713
4714/// `POST /bot/claims` — headless, shared-secret mint of a claim link.
4715///
4716/// Auth is a bearer shared secret in the `X-Bot-Secret` header (== the Fly secret
4717/// `FEATHERREADER_BOT_SECRET`), NOT an OAuth cookie — so the homelab-hosted bot
4718/// can call it. When `FEATHERREADER_BOT_SECRET` is unset the endpoint is DISABLED
4719/// (503), so a bare/dev instance never exposes an unauthenticated mint.
4720///
4721/// Server-side DID idempotency (the authoritative dedupe backstop): the request
4722/// body carries the follower `did`. The app — not the bot's local SQLite — is the
4723/// source of truth, so a bot-host state loss cannot re-mint or re-post per
4724/// follower:
4725///   * DID already holds beta access → `200 {status:"already_seated"}` (empty
4726///     code/url; the bot marks it handled and posts NOTHING);
4727///   * DID already has an outstanding active claim → `200 {status:"existing"}`
4728///     returning the SAME code/token/url (idempotent — never a second mint);
4729///   * otherwise mint a fresh code recorded FOR that DID → `200 {status:"minted"}`.
4730///
4731/// Cap accounting: the bot must not promise more claims than seats remain, so
4732/// this refuses with `409 Conflict {"error":"full"}` when
4733/// `beta_access + outstanding active codes >= FEATHERREADER_BETA_CAP`. (The
4734/// redeem-time cap in `store::redeem_code` is still the hard backstop.) The count
4735/// queries FAIL CLOSED: a DB error propagates as `500` rather than reading 0 and
4736/// minting past the cap.
4737///
4738/// On a fresh mint it uses the generous claim TTL (`FEATHERREADER_CLAIM_TTL_SECS`,
4739/// default 14d — the admin browser flow's 30-min TTL would expire before the
4740/// follower taps an async-delivered link).
4741async fn bot_mint_claim(
4742    State(state): State<AppState>,
4743    headers: HeaderMap,
4744    body: axum::body::Bytes,
4745) -> Response {
4746    // 1. The endpoint is OFF unless a bot secret is configured.
4747    let bot_secret = match state.config.bot_secret.as_deref() {
4748        Some(s) => s,
4749        None => {
4750            warn!(
4751                "POST /bot/claims called but FEATHERREADER_BOT_SECRET is unset (endpoint disabled)"
4752            );
4753            return (
4754                StatusCode::SERVICE_UNAVAILABLE,
4755                "bot mint endpoint disabled (FEATHERREADER_BOT_SECRET unset)\n",
4756            )
4757                .into_response();
4758        }
4759    };
4760
4761    // 2. Constant-time bearer check on the X-Bot-Secret header.
4762    let presented = headers
4763        .get("x-bot-secret")
4764        .and_then(|v| v.to_str().ok())
4765        .unwrap_or("");
4766    if !bot_secret_matches(presented, bot_secret) {
4767        warn!("POST /bot/claims rejected: bad or missing X-Bot-Secret");
4768        return (StatusCode::UNAUTHORIZED, "bad bot secret\n").into_response();
4769    }
4770
4771    // 2b. Parse the (optional) JSON body → the follower DID/handle. An empty body
4772    // (legacy caller) parses to an all-None request; a malformed body is a 400.
4773    let req: BotClaimRequest = if body.is_empty() {
4774        BotClaimRequest::default()
4775    } else {
4776        match serde_json::from_slice(&body) {
4777            Ok(r) => r,
4778            Err(err) => {
4779                warn!(%err, "POST /bot/claims: bad JSON body");
4780                return (StatusCode::BAD_REQUEST, "bad json body\n").into_response();
4781            }
4782        }
4783    };
4784    let follower_did = req.did.as_deref().filter(|d| !d.is_empty());
4785
4786    // 3. Server-side DID idempotency (only when a DID was supplied):
4787    if let Some(did) = follower_did {
4788        // 3a. Already seated → tell the bot to post nothing.
4789        match store::has_beta_access(&state.db, did).await {
4790            Ok(true) => {
4791                info!("bot mint: DID already holds beta access; already_seated");
4792                return bot_claim_json(BotClaimResponse {
4793                    status: "already_seated",
4794                    code: String::new(),
4795                    token: String::new(),
4796                    url: String::new(),
4797                });
4798            }
4799            Ok(false) => {}
4800            Err(err) => {
4801                // Fail closed: a DB error must not fall through to a fresh mint.
4802                warn!(%err, "bot mint: has_beta_access failed");
4803                return (StatusCode::INTERNAL_SERVER_ERROR, "lookup failed\n").into_response();
4804            }
4805        }
4806        // 3b. Outstanding active claim for this DID → return the SAME code (no
4807        // second mint). This is what survives a bot-host state loss.
4808        match store::find_active_code_for_did(&state.db, did).await {
4809            Ok(Some(code)) => {
4810                info!("bot mint: existing outstanding claim for DID; returning same code");
4811                let token = sign_claim_token(&code, &state.config.cookie_secret);
4812                let url = format!("{}/claim?t={}", state.config.public_url, qenc(&token));
4813                return bot_claim_json(BotClaimResponse {
4814                    status: "existing",
4815                    code,
4816                    token,
4817                    url,
4818                });
4819            }
4820            Ok(None) => {}
4821            Err(err) => {
4822                warn!(%err, "bot mint: find_active_code_for_did failed");
4823                return (StatusCode::INTERNAL_SERVER_ERROR, "lookup failed\n").into_response();
4824            }
4825        }
4826    }
4827
4828    // 4. Cap accounting: seats already granted + outstanding unredeemed codes.
4829    //    FAIL CLOSED — a count error is a 500, not a silent mint past the cap.
4830    let granted = match store::count_beta_access(&state.db).await {
4831        Ok(n) => n,
4832        Err(err) => {
4833            warn!(%err, "bot mint: count_beta_access failed; failing closed");
4834            return (StatusCode::INTERNAL_SERVER_ERROR, "count failed\n").into_response();
4835        }
4836    };
4837    let outstanding = match store::count_active_codes(&state.db).await {
4838        Ok(n) => n,
4839        Err(err) => {
4840            warn!(%err, "bot mint: count_active_codes failed; failing closed");
4841            return (StatusCode::INTERNAL_SERVER_ERROR, "count failed\n").into_response();
4842        }
4843    };
4844    if granted + outstanding >= state.config.beta_cap {
4845        info!(
4846            granted,
4847            outstanding,
4848            cap = state.config.beta_cap,
4849            "bot mint refused: at capacity"
4850        );
4851        return (
4852            StatusCode::CONFLICT,
4853            [(header::CONTENT_TYPE, "application/json")],
4854            "{\"error\":\"full\"}\n",
4855        )
4856            .into_response();
4857    }
4858
4859    // 5. Mint with the generous claim TTL, recording the follower DID (when given)
4860    //    so a re-request for the same DID returns THIS code idempotently.
4861    let bot_did = state
4862        .config
4863        .admin_seed_dids()
4864        .first()
4865        .cloned()
4866        .unwrap_or_else(|| "did:bot:featherreader".to_string());
4867    let minted = match follower_did {
4868        Some(did) => {
4869            store::mint_code_for_did(&state.db, &bot_did, state.config.claim_ttl_secs, did).await
4870        }
4871        None => store::mint_code(&state.db, &bot_did, state.config.claim_ttl_secs).await,
4872    };
4873    let code = match minted {
4874        Ok(c) => c,
4875        // S4: the dedupe check (3b) and this mint are separate statements, so two
4876        // concurrent requests for one DID can both fall through 3b's `Ok(None)`.
4877        // The partial unique index `idx_invite_codes_intended_active` makes the
4878        // loser's INSERT fail (only one active row per intended DID), which
4879        // surfaces here as a conflict. Recover by returning the winner's existing
4880        // code (same shape as the 3b idempotent path) instead of a 500.
4881        Err(err) if follower_did.is_some() && store::is_intended_active_conflict(&err) => {
4882            match store::find_active_code_for_did(&state.db, follower_did.unwrap()).await {
4883                Ok(Some(code)) => {
4884                    info!("bot mint: lost the mint race; returning the concurrently-minted code");
4885                    let token = sign_claim_token(&code, &state.config.cookie_secret);
4886                    let url = format!("{}/claim?t={}", state.config.public_url, qenc(&token));
4887                    return bot_claim_json(BotClaimResponse {
4888                        status: "existing",
4889                        code,
4890                        token,
4891                        url,
4892                    });
4893                }
4894                // The winner's row vanished between the conflict and this lookup
4895                // (redeemed/expired/purged in the gap) — nothing to hand back.
4896                // Fail closed rather than silently mint past the just-hit guard.
4897                Ok(None) => {
4898                    warn!("bot mint: conflict but no active code found on recovery");
4899                    return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
4900                }
4901                Err(err) => {
4902                    warn!(%err, "bot mint: recovery lookup after conflict failed");
4903                    return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
4904                }
4905            }
4906        }
4907        Err(err) => {
4908            warn!(%err, "bot mint_code failed");
4909            return (StatusCode::INTERNAL_SERVER_ERROR, "mint failed\n").into_response();
4910        }
4911    };
4912    let token = sign_claim_token(&code, &state.config.cookie_secret);
4913    let url = format!("{}/claim?t={}", state.config.public_url, qenc(&token));
4914    info!("bot minted a claim code + token");
4915
4916    bot_claim_json(BotClaimResponse {
4917        status: "minted",
4918        code,
4919        token,
4920        url,
4921    })
4922}
4923
4924/// Serialize a [`BotClaimResponse`] to a `200 application/json` response (or a
4925/// `500` if serialization somehow fails).
4926fn bot_claim_json(resp: BotClaimResponse) -> Response {
4927    match serde_json::to_string(&resp) {
4928        Ok(body) => (
4929            StatusCode::OK,
4930            [(header::CONTENT_TYPE, "application/json")],
4931            body,
4932        )
4933            .into_response(),
4934        Err(err) => {
4935            warn!(%err, "serializing bot claim response failed");
4936            (StatusCode::INTERNAL_SERVER_ERROR, "serialize failed\n").into_response()
4937        }
4938    }
4939}
4940
4941/// Constant-time equality for the bot bearer secret (avoid a timing side-channel
4942/// on the shared secret). Delegates to the same `cookie::constant_time_eq` used
4943/// by the HMAC checks so there is one comparator to audit; a length mismatch
4944/// short-circuits to `false`, which is fine — the secret length isn't sensitive.
4945fn bot_secret_matches(presented: &str, expected: &str) -> bool {
4946    cookie::constant_time_eq(presented.as_bytes(), expected.as_bytes())
4947}
4948
4949// ---------------------------------------------------------------------------
4950// Signed, short-lived invite cookie (reuses the session-cookie HMAC helper)
4951// ---------------------------------------------------------------------------
4952
4953/// Sign the reserved invite `code` into a short-lived `Set-Cookie` value. Reuses
4954/// the same HMAC-SHA256 helper as the session cookie; the payload is the code
4955/// itself (base64url) rather than an opaque sid, since the code IS the reserved
4956/// intent the callback consumes.
4957fn sign_invite(code: &str, secret: &str) -> String {
4958    cookie::sign_value(INVITE_COOKIE, code, secret, INVITE_TTL_SECS)
4959}
4960
4961/// Verify + read the reserved invite code out of the request's invite cookie
4962/// (`None` if absent, tampered, or forged). No expiry is enforced here beyond
4963/// the cookie's own `Max-Age`; the atomic `redeem_code` at the callback is the
4964/// authority on the code's live status.
4965fn invite_cookie_code(headers: &HeaderMap, secret: &str) -> Option<String> {
4966    cookie::verify_value(headers, INVITE_COOKIE, secret)
4967}
4968
4969/// Domain-separation label for the claim TOKEN's HMAC (distinct from the
4970/// `fr_invite`/`fr_session` cookie names), so a token can never be replayed as a
4971/// cookie value and vice-versa.
4972const CLAIM_TOKEN_LABEL: &str = "claim-token";
4973
4974/// Sign an invite `code` into a URL-safe claim TOKEN: `b64url(code).<sig>`
4975/// (HMAC-SHA256 over `"claim-token" || 0x00 || code`).
4976///
4977/// NOTE — the token is NOT confidential: the `b64url(code)` half is trivially
4978/// decodable by anyone, so the raw `FEATHER-…` code is effectively public in the
4979/// claim URL. The token's security is INTEGRITY + SINGLE-USE, not secrecy: the
4980/// `<sig>` HMAC means only this instance can MINT a valid token (a forged/guessed
4981/// code won't verify), the wrapped code is single-use (redeem flips
4982/// `active→redeemed`), and `/claim` is per-IP rate-limited. Wrapping keeps the
4983/// token one self-contained string needing no server-side token table; it does
4984/// NOT hide the code.
4985fn sign_claim_token(code: &str, secret: &str) -> String {
4986    cookie::sign_token(CLAIM_TOKEN_LABEL, code, secret)
4987}
4988
4989/// Verify a claim token and return the invite code it wraps (`None` on a tampered
4990/// / forged / malformed token). The code's live status (active/unexpired/seat
4991/// free) is re-checked by `preflight_code`; this only proves the token was minted
4992/// by this instance.
4993fn claim_token_code(token: &str, secret: &str) -> Option<String> {
4994    cookie::verify_token(CLAIM_TOKEN_LABEL, token, secret)
4995}
4996
4997/// Clear the invite cookie on a response (after a successful bind, or when the
4998/// reservation turned out to be stale).
4999fn clear_invite_cookie(resp: &mut Response) {
5000    set_cookie(
5001        resp,
5002        &format!("{INVITE_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"),
5003    );
5004}
5005
5006// ---------------------------------------------------------------------------
5007// OPML import + export
5008// ---------------------------------------------------------------------------
5009
5010/// `POST /opml` — import subscriptions from an OPML document.
5011///
5012/// Accepts either a multipart file upload (field `file`) or a pasted textarea
5013/// (field `opml`). The parsed feeds each become a `community.lexicon.rss.folder`
5014/// (for any named folders) + a `community.lexicon.rss.subscription` record in the
5015/// user's PDS via the records layer's bulk-add (`add_subscriptions_bulk`, one
5016/// `applyWrites` round-trip). Feeds are also upserted into the local cache so
5017/// they show immediately; polling is left to the background poller.
5018async fn import_opml(
5019    State(state): State<AppState>,
5020    headers: HeaderMap,
5021    mut multipart: Multipart,
5022) -> Result<Response, WebError> {
5023    let did = match current_did(&state, &headers).await {
5024        Some(d) => d,
5025        None => return Ok(Redirect::to("/login").into_response()),
5026    };
5027    let pool = &state.db;
5028
5029    // Collect the OPML text from whichever field carried it. Multipart errors
5030    // are mapped to their axum-native response so that an over-cap upload (the
5031    // `DefaultBodyLimit` on this route, see `OPML_BODY_LIMIT`) surfaces as
5032    // `413 Payload Too Large` rather than being swallowed by the blanket
5033    // `WebError` → `500` conversion.
5034    let mut opml_text = String::new();
5035    while let Some(field) = multipart.next_field().await.map_err(multipart_response)? {
5036        let name = field.name().unwrap_or("").to_string();
5037        if name == "opml" || name == "file" {
5038            let bytes = field.bytes().await.map_err(multipart_response)?;
5039            if !bytes.is_empty() {
5040                opml_text = String::from_utf8_lossy(&bytes).into_owned();
5041                if name == "file" {
5042                    break;
5043                }
5044            }
5045        }
5046    }
5047
5048    // A parse FAILURE and an empty-but-valid file are different things, and
5049    // `unwrap_or_default` collapsed them: a malformed export was reported to the
5050    // reader as "No feeds found in that OPML", which sends them looking at their
5051    // old reader for feeds that are right there in the file.
5052    let feeds =
5053        match opml::parse_opml(&opml_text) {
5054            Ok(feeds) => feeds,
5055            Err(err) => {
5056                warn!(%err, %did, "OPML import could not parse the uploaded file");
5057                return Ok(Redirect::to(&format!(
5058                "/?flash={}",
5059                qenc("That file could not be read as OPML. Export it again from your other reader?")
5060            ))
5061                .into_response());
5062            }
5063        };
5064    if feeds.is_empty() {
5065        info!(%did, "OPML import found no feeds");
5066        return Ok(
5067            Redirect::to(&format!("/?flash={}", qenc("No feeds found in that OPML")))
5068                .into_response(),
5069        );
5070    }
5071
5072    // Create any named folders first, mapping folder name → at:// URI so
5073    // subscriptions can reference them.
5074    let now = now_rfc3339();
5075    let mut folder_uris: std::collections::HashMap<String, String> =
5076        std::collections::HashMap::new();
5077    // Reuse existing folders where the name already exists.
5078    if let Ok(existing) = state.repo().list_folders_sorted(&did).await {
5079        for (rkey, folder) in existing {
5080            folder_uris
5081                .entry(folder.name.clone())
5082                .or_insert_with(|| folder_uri(&did, &rkey));
5083        }
5084    }
5085    let mut wanted_folders: Vec<String> = feeds
5086        .iter()
5087        .filter_map(|f| f.folder.clone())
5088        .filter(|n| !n.is_empty())
5089        .collect();
5090    wanted_folders.sort();
5091    wanted_folders.dedup();
5092    for name in wanted_folders {
5093        if folder_uris.contains_key(&name) {
5094            continue;
5095        }
5096        let folder = Folder::new(name.clone(), now.clone());
5097        match state.repo().add_folder(&did, &folder).await {
5098            Ok(rkey) => {
5099                folder_uris.insert(name, folder_uri(&did, &rkey));
5100            }
5101            Err(err) => warn!(%err, %did, "OPML folder create failed"),
5102        }
5103    }
5104
5105    // Build one subscription record per PUBLIC feed + upsert the local cache row.
5106    // Private/paid feeds are SKIPPED entirely (never stored, fetched, or written)
5107    // and reported back to the user — the same public-feeds-only stance as the
5108    // single-add path, so an OPML import can't leak a Substack/Patreon/podcast
5109    // token onto the public network either.
5110    // Per-DID subscription cap: an OPML import must not blow past the cap. Compute
5111    // the remaining headroom (cap − existing) once; public feeds beyond it are
5112    // TRIMMED (not imported) and reported. `<= 0` disables the cap.
5113    let sub_cap = state.config.max_subs_per_did;
5114    let mut headroom: Option<i64> = if sub_cap > 0 {
5115        let existing = store::count_subscriptions_for_did(pool, &did)
5116            .await
5117            .unwrap_or(0);
5118        Some((sub_cap - existing).max(0))
5119    } else {
5120        None
5121    };
5122    let mut trimmed_over_cap: usize = 0;
5123
5124    // Global feeds ceiling: an OPML import must not blow past the shared cache
5125    // ceiling any more than the single-add path may. Seed the remaining global
5126    // headroom (cap − current feeds) once, and only a BRAND-NEW feed URL (one
5127    // not already cached) consumes it. Existing/duplicate URLs add no row and
5128    // are always allowed. Feeds past the ceiling are TRIMMED and reported.
5129    // `<= 0` disables the ceiling.
5130    let feeds_cap = state.config.max_feeds_global;
5131    let mut global_headroom: Option<i64> = if feeds_cap > 0 {
5132        let existing = store::count_feeds(pool).await.unwrap_or(0);
5133        Some((feeds_cap - existing).max(0))
5134    } else {
5135        None
5136    };
5137    let mut trimmed_over_global: usize = 0;
5138
5139    let mut subs = Vec::with_capacity(feeds.len());
5140    let mut skipped_private: Vec<String> = Vec::new();
5141    // Imported into the PDS but not cached locally, so not pollable until the
5142    // next import touches them. Counted rather than only logged — see below.
5143    let mut uncached: usize = 0;
5144    // Entries this instance cannot store at all (an `at://` publication with
5145    // the flag off, an unsupported scheme). Counted, because the `continue`
5146    // below used to increment nothing while the privacy branch beside it
5147    // produced a label — so an OPML from a standard.site-enabled instance
5148    // imported "successfully" with entries missing and no reason given.
5149    let mut skipped_unsupported: usize = 0;
5150    for f in &feeds {
5151        // `xmlUrl` is whatever the uploaded file says, and nothing on this path
5152        // ever parsed it — the single-add path can't reach here because
5153        // `resolve_feed_url` must parse AND successfully fetch first. So
5154        // `javascript:alert(1)` and `file:///etc/passwd` were both accepted,
5155        // cached, and published as records to the user's PUBLIC repo. Note that
5156        // `classify_feed_privacy` does not catch these: both parse cleanly, and
5157        // it returns `Public` for anything unparseable by design.
5158        if !feed::is_storable_feed_url(&f.feed_url, state.config.standard_site) {
5159            info!(
5160                %did,
5161                "skipped an OPML entry whose xmlUrl is not a storable feed URL"
5162            );
5163            skipped_unsupported += 1;
5164            continue;
5165        }
5166        if let feed::FeedPrivacy::Private(reason) = feed::classify_feed_privacy(&f.feed_url) {
5167            info!(feed = %f.feed_url, %reason, %did, "skipped private/paid feed on OPML import (not stored)");
5168            // Report by title where we have one, else the (public-safe) host.
5169            let label = f
5170                .title
5171                .clone()
5172                .filter(|t| !t.trim().is_empty())
5173                .unwrap_or_else(|| private_feed_label(&f.feed_url));
5174            skipped_private.push(label);
5175            continue;
5176        }
5177
5178        // Over-cap: stop importing once headroom is exhausted (count the rest so
5179        // we can tell the user how many were dropped).
5180        if let Some(h) = headroom.as_mut() {
5181            if *h <= 0 {
5182                trimmed_over_cap += 1;
5183                continue;
5184            }
5185        }
5186
5187        // Global ceiling: a brand-new feed URL consumes global headroom. Once
5188        // it's exhausted, refuse to cache further NEW feeds (existing URLs are
5189        // free — they add no row). Checked before decrementing the per-DID
5190        // headroom so a dropped feed doesn't burn the caller's own quota.
5191        let is_new = match store::get_feed_by_url(pool, &f.feed_url).await {
5192            Ok(existing) => existing.is_none(),
5193            // On a lookup error, treat as existing (don't consume global
5194            // headroom) but still allow the upsert to proceed.
5195            Err(err) => {
5196                warn!(%err, feed = %f.feed_url, "get_feed_by_url failed during OPML global-cap check");
5197                false
5198            }
5199        };
5200        if is_new {
5201            if let Some(g) = global_headroom.as_mut() {
5202                if *g <= 0 {
5203                    trimmed_over_global += 1;
5204                    continue;
5205                }
5206                *g -= 1;
5207            }
5208        }
5209
5210        // Passed both caps: consume the per-DID headroom now that the feed is
5211        // actually being imported.
5212        if let Some(h) = headroom.as_mut() {
5213            *h -= 1;
5214        }
5215
5216        let mut sub = Subscription::new(f.feed_url.clone(), now.clone());
5217        sub.title = f.title.clone();
5218        sub.site_url = f.site_url.clone();
5219        sub.folder = f
5220            .folder
5221            .as_ref()
5222            .and_then(|name| folder_uris.get(name).cloned());
5223        subs.push(sub);
5224        // Same support ticket as the single-add path: no `feeds` row means the
5225        // poller never selects this subscription, so the import looks like it
5226        // worked and the feed silently never updates. Counted as well as logged,
5227        // because one line per feed in a 200-feed import is not something anyone
5228        // reads — the count goes to the reader.
5229        if let Err(err) = store::upsert_feed(
5230            pool,
5231            &store::NewFeed {
5232                url: f.feed_url.clone(),
5233                title: f.title.clone(),
5234                site_url: f.site_url.clone(),
5235                ..Default::default()
5236            },
5237        )
5238        .await
5239        {
5240            warn!(%err, %did, url = %f.feed_url, "OPML import could not cache a feed; \
5241                                                  it will not be polled");
5242            uncached += 1;
5243        }
5244    }
5245
5246    // **A failed PDS write is not an import.**
5247    //
5248    // The subscriptions live in the reader's repo; a local `feeds` row is just a
5249    // poller hint. This used to `warn!` and then report "Imported N feeds"
5250    // regardless, so a total failure read as a total success — and the reader
5251    // would only discover otherwise on their next visit, with an empty sidebar.
5252    let pds_written = match state.repo().add_subscriptions_bulk(&did, &subs).await {
5253        Ok(rkeys) => {
5254            info!(%did, count = rkeys.len(), skipped = skipped_private.len(), "imported OPML subscriptions to PDS (batched)");
5255            true
5256        }
5257        Err(err) => {
5258            warn!(%err, %did, "OPML PDS batch write failed (feeds cached locally)");
5259            false
5260        }
5261    };
5262    if !pds_written {
5263        return Ok(Redirect::to(&format!(
5264            "/?flash={}",
5265            qenc(
5266                "Could not save those subscriptions to your PDS, so nothing was imported. \
5267                 Try again in a moment."
5268            )
5269        ))
5270        .into_response());
5271    }
5272
5273    // Report the import count, plus any private/paid feeds skipped as unsupported.
5274    let mut flash = format!("Imported {} feeds", subs.len());
5275    if uncached > 0 {
5276        flash.push_str(&format!(
5277            ". {uncached} of them could not be cached locally and may not update until the next import."
5278        ));
5279    }
5280    if trimmed_over_cap > 0 {
5281        flash.push_str(&format!(
5282            ". {trimmed_over_cap} feed(s) not imported: your subscription limit ({sub_cap}) was reached."
5283        ));
5284    }
5285    if trimmed_over_global > 0 {
5286        flash.push_str(&format!(
5287            ". {trimmed_over_global} feed(s) not imported: this instance is at its feed capacity right now."
5288        ));
5289    }
5290    if !skipped_private.is_empty() {
5291        flash.push_str(&format!(
5292            ". {} feed(s) skipped as private/paid: {} — not supported yet (public feeds only for now).",
5293            skipped_private.len(),
5294            skipped_private.join(", ")
5295        ));
5296    }
5297    if skipped_unsupported > 0 {
5298        // By count only — the URL is whatever the file said, and unlike the
5299        // private branch there is no public-safe label to give.
5300        flash.push_str(&format!(
5301            ". {skipped_unsupported} feed(s) skipped: not a kind of feed this instance can subscribe to."
5302        ));
5303    }
5304    Ok(Redirect::to(&format!("/?flash={}", qenc(&flash))).into_response())
5305}
5306
5307/// A public-safe label for a skipped private feed when it has no title: just the
5308/// host, so we never echo the secret-bearing path/query back to the user.
5309fn private_feed_label(url: &str) -> String {
5310    url::Url::parse(url)
5311        .ok()
5312        .and_then(|u| u.host_str().map(str::to_string))
5313        .unwrap_or_else(|| "a private feed".to_string())
5314}
5315
5316/// `GET /opml/export` — export the user's subscriptions + folders as OPML.
5317async fn export_opml(
5318    State(state): State<AppState>,
5319    headers: HeaderMap,
5320) -> Result<Response, WebError> {
5321    let did = match current_did(&state, &headers).await {
5322        Some(d) => d,
5323        None => return Ok(Redirect::to("/login").into_response()),
5324    };
5325
5326    // **An export must never be silently empty.** `unwrap_or_default` here turned
5327    // a failed read into a 200 carrying a zero-feed OPML file — the reader's
5328    // backup, blank, at exactly the moment they reached for it. That was survivable
5329    // while a truncated walk returned `Ok`; now that the walk refuses a short list,
5330    // this is the one caller that converts a refusal into data loss, and it is also
5331    // the recovery route the changelog points a locked-out reader at.
5332    let subs = match state.repo().list_subscriptions_sorted(&did).await {
5333        Ok(subs) => subs,
5334        Err(err) => {
5335            tracing::warn!(%err, did = %did, "refusing to export an OPML we could not read in full");
5336            return Ok(Redirect::to(&format!(
5337                "/manage?flash={}",
5338                qenc(EXPORT_INCOMPLETE_REFUSAL)
5339            ))
5340            .into_response());
5341        }
5342    };
5343    let folders = match state.repo().list_folders_sorted(&did).await {
5344        Ok(folders) => folders,
5345        Err(err) => {
5346            tracing::warn!(%err, did = %did, "refusing to export an OPML without its folders");
5347            return Ok(Redirect::to(&format!(
5348                "/manage?flash={}",
5349                qenc(EXPORT_INCOMPLETE_REFUSAL)
5350            ))
5351            .into_response());
5352        }
5353    };
5354    // The exporter matches a subscription's `folder` at-uri against the folder's
5355    // pair key; our folder pairs are keyed by rkey, so rebuild them as at-uris.
5356    let folder_pairs: Vec<(String, Folder)> = folders
5357        .into_iter()
5358        .map(|(rkey, f)| (folder_uri(&did, &rkey), f))
5359        .collect();
5360
5361    let body = opml::to_opml(&subs, &folder_pairs);
5362    let mut resp = (StatusCode::OK, body).into_response();
5363    resp.headers_mut().insert(
5364        header::CONTENT_TYPE,
5365        "text/x-opml; charset=utf-8".parse().unwrap(),
5366    );
5367    resp.headers_mut().insert(
5368        header::CONTENT_DISPOSITION,
5369        "attachment; filename=\"featherreader-subscriptions.opml\""
5370            .parse()
5371            .unwrap(),
5372    );
5373    Ok(resp)
5374}
5375
5376// ---------------------------------------------------------------------------
5377// Signed session cookie (HMAC-SHA256, dependency-free)
5378// ---------------------------------------------------------------------------
5379
5380/// Set a `Set-Cookie` header on a response (append, so logout+redirect compose).
5381fn set_cookie(resp: &mut Response, cookie: &str) {
5382    if let Ok(value) = axum::http::HeaderValue::from_str(cookie) {
5383        resp.headers_mut()
5384            .append(axum::http::header::SET_COOKIE, value);
5385    }
5386}
5387
5388/// Whether the request came from htmx (the `HX-Request` header).
5389fn is_htmx(headers: &HeaderMap) -> bool {
5390    headers
5391        .get("HX-Request")
5392        .is_some_and(|v| v.as_bytes().eq_ignore_ascii_case(b"true"))
5393}
5394
5395/// Whether a mark-read / star request originated from the single-entry READER
5396/// (as opposed to the list view). The reader's forms tag themselves with
5397/// `X-FR-Reader: 1` via `hx-headers`; the list view's do not. This selects the
5398/// swap fragment: the reader gets an out-of-band action-bar update (its `<li>`
5399/// isn't in the DOM), the list gets the row (`entry_row.html`).
5400fn is_reader_request(headers: &HeaderMap) -> bool {
5401    headers
5402        .get("X-FR-Reader")
5403        .is_some_and(|v| v.as_bytes() == b"1")
5404}
5405
5406/// A tiny, self-contained signed-cookie layer: HMAC-SHA256 over an opaque,
5407/// server-minted **session id** (never the DID — so the cookie can't be forged
5408/// from a resolved victim DID; forging it needs the HMAC secret *and* a live
5409/// server-side session id).
5410mod cookie {
5411    use super::{HeaderMap, SESSION_COOKIE};
5412
5413    /// Sign a session id into a `Set-Cookie` header value: `fr_session=<sid>.<sig>`.
5414    pub fn sign_session(sid: &str, secret: &str) -> String {
5415        sign_value(SESSION_COOKIE, sid, secret, 2_592_000)
5416    }
5417
5418    /// Verify the request's session cookie and return the session id it carries.
5419    pub fn verify_session(headers: &HeaderMap, secret: &str) -> Option<String> {
5420        verify_value(headers, SESSION_COOKIE, secret)
5421    }
5422
5423    /// The HMAC message binding the cookie NAME to its value (`name || 0x00 ||
5424    /// value`), so a signature minted for one cookie can't verify under another —
5425    /// e.g. a value validly signed as `fr_invite` is not accepted as `fr_session`.
5426    /// The NUL separator can't appear in a cookie name, so the encoding is
5427    /// unambiguous.
5428    fn cookie_hmac_msg(name: &str, value: &str) -> Vec<u8> {
5429        let mut msg = Vec::with_capacity(name.len() + 1 + value.len());
5430        msg.extend_from_slice(name.as_bytes());
5431        msg.push(0);
5432        msg.extend_from_slice(value.as_bytes());
5433        msg
5434    }
5435
5436    /// Sign an arbitrary string `value` into a `Set-Cookie` header for `name`,
5437    /// HMAC-SHA256 over `name || 0x00 || value`: `name=<b64url(value)>.<sig>`. The
5438    /// generic form behind both the session cookie and the short-lived invite
5439    /// cookie; domain-separating by name keeps a signature valid only for the
5440    /// cookie it was minted for.
5441    pub fn sign_value(name: &str, value: &str, secret: &str, max_age_secs: i64) -> String {
5442        let sig = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(name, value));
5443        let b64 = b64url_encode(value.as_bytes());
5444        format!(
5445            "{name}={b64}.{sig}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age={max_age_secs}"
5446        )
5447    }
5448
5449    /// Verify + read a value out of the named signed cookie (`None` on absent /
5450    /// tampered / forged / cross-cookie). The generic form behind both readers.
5451    pub fn verify_value(headers: &HeaderMap, name: &str, secret: &str) -> Option<String> {
5452        let raw = cookie_value(headers, name)?;
5453        let (b64, sig) = raw.split_once('.')?;
5454        let bytes = b64url_decode(b64)?;
5455        let value = String::from_utf8(bytes).ok()?;
5456        let expected = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(name, &value));
5457        if constant_time_eq(expected.as_bytes(), sig.as_bytes()) {
5458            Some(value)
5459        } else {
5460            None
5461        }
5462    }
5463
5464    /// Sign an arbitrary `value` into an opaque, URL-safe token string
5465    /// `b64url(value).<sig>` (HMAC-SHA256 over `label || 0x00 || value`). Unlike
5466    /// [`sign_value`] this is NOT a `Set-Cookie` header — it's a bare token for a
5467    /// URL query param (the bot's claim link). `label` domain-separates it from
5468    /// the cookies so a token can't be replayed as a cookie value.
5469    pub fn sign_token(label: &str, value: &str, secret: &str) -> String {
5470        let sig = hmac_sha256_hex(secret.as_bytes(), &cookie_hmac_msg(label, value));
5471        let b64 = b64url_encode(value.as_bytes());
5472        format!("{b64}.{sig}")
5473    }
5474
5475    /// Verify a token minted by [`sign_token`] and return the wrapped value
5476    /// (`None` on tamper / forge / malformed). Constant-time signature compare.
5477    pub fn verify_token(label: &str, token: &str, secret: &str) -> Option<String> {
5478        let (b64, sig) = token.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(label, &value));
5482        if constant_time_eq(expected.as_bytes(), sig.as_bytes()) {
5483            Some(value)
5484        } else {
5485            None
5486        }
5487    }
5488
5489    /// Pull one cookie value out of the `Cookie` request header.
5490    fn cookie_value(headers: &HeaderMap, name: &str) -> Option<String> {
5491        let header = headers.get(axum::http::header::COOKIE)?.to_str().ok()?;
5492        for part in header.split(';') {
5493            let part = part.trim();
5494            if let Some((k, v)) = part.split_once('=') {
5495                if k == name {
5496                    return Some(v.to_string());
5497                }
5498            }
5499        }
5500        None
5501    }
5502
5503    /// Constant-time byte comparison (avoid signature-timing leaks). Public
5504    /// within the module so the bot-secret bearer check reuses the exact same
5505    /// comparator as the cookie/token HMAC checks (one implementation to audit).
5506    pub fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
5507        if a.len() != b.len() {
5508            return false;
5509        }
5510        let mut diff = 0u8;
5511        for (x, y) in a.iter().zip(b.iter()) {
5512            diff |= x ^ y;
5513        }
5514        diff == 0
5515    }
5516
5517    // -- URL-safe base64 (no padding), std-only --------------------------------
5518
5519    const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
5520
5521    fn b64url_encode(input: &[u8]) -> String {
5522        let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
5523        for chunk in input.chunks(3) {
5524            let b = [
5525                chunk[0],
5526                *chunk.get(1).unwrap_or(&0),
5527                *chunk.get(2).unwrap_or(&0),
5528            ];
5529            let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | (b[2] as u32);
5530            out.push(B64[((n >> 18) & 63) as usize] as char);
5531            out.push(B64[((n >> 12) & 63) as usize] as char);
5532            if chunk.len() > 1 {
5533                out.push(B64[((n >> 6) & 63) as usize] as char);
5534            }
5535            if chunk.len() > 2 {
5536                out.push(B64[(n & 63) as usize] as char);
5537            }
5538        }
5539        out
5540    }
5541
5542    fn b64url_decode(input: &str) -> Option<Vec<u8>> {
5543        fn val(c: u8) -> Option<u32> {
5544            match c {
5545                b'A'..=b'Z' => Some((c - b'A') as u32),
5546                b'a'..=b'z' => Some((c - b'a' + 26) as u32),
5547                b'0'..=b'9' => Some((c - b'0' + 52) as u32),
5548                b'-' => Some(62),
5549                b'_' => Some(63),
5550                _ => None,
5551            }
5552        }
5553        let bytes = input.as_bytes();
5554        let mut out = Vec::with_capacity(input.len() / 4 * 3 + 2);
5555        for chunk in bytes.chunks(4) {
5556            let mut n = 0u32;
5557            let mut valid = 0;
5558            for (i, &c) in chunk.iter().enumerate() {
5559                n |= val(c)? << (18 - 6 * i);
5560                valid += 1;
5561            }
5562            out.push((n >> 16) as u8);
5563            if valid > 2 {
5564                out.push((n >> 8) as u8);
5565            }
5566            if valid > 3 {
5567                out.push(n as u8);
5568            }
5569        }
5570        Some(out)
5571    }
5572
5573    // -- HMAC-SHA256, std-only -------------------------------------------------
5574
5575    /// HMAC-SHA256(key, msg) as lowercase hex.
5576    fn hmac_sha256_hex(key: &[u8], msg: &[u8]) -> String {
5577        const BLOCK: usize = 64;
5578        let mut k = [0u8; BLOCK];
5579        if key.len() > BLOCK {
5580            let d = sha256(key);
5581            k[..32].copy_from_slice(&d);
5582        } else {
5583            k[..key.len()].copy_from_slice(key);
5584        }
5585        let mut ipad = [0x36u8; BLOCK];
5586        let mut opad = [0x5cu8; BLOCK];
5587        for i in 0..BLOCK {
5588            ipad[i] ^= k[i];
5589            opad[i] ^= k[i];
5590        }
5591        let mut inner = Vec::with_capacity(BLOCK + msg.len());
5592        inner.extend_from_slice(&ipad);
5593        inner.extend_from_slice(msg);
5594        let inner_hash = sha256(&inner);
5595        let mut outer = Vec::with_capacity(BLOCK + 32);
5596        outer.extend_from_slice(&opad);
5597        outer.extend_from_slice(&inner_hash);
5598        let mac = sha256(&outer);
5599        let mut hex = String::with_capacity(64);
5600        for b in mac {
5601            hex.push_str(&format!("{b:02x}"));
5602        }
5603        hex
5604    }
5605
5606    /// SHA-256 (FIPS 180-4), std-only.
5607    fn sha256(data: &[u8]) -> [u8; 32] {
5608        const K: [u32; 64] = [
5609            0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4,
5610            0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe,
5611            0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f,
5612            0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7,
5613            0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc,
5614            0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b,
5615            0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116,
5616            0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
5617            0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7,
5618            0xc67178f2,
5619        ];
5620        let mut h: [u32; 8] = [
5621            0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab,
5622            0x5be0cd19,
5623        ];
5624
5625        let bit_len = (data.len() as u64) * 8;
5626        let mut msg = data.to_vec();
5627        msg.push(0x80);
5628        while msg.len() % 64 != 56 {
5629            msg.push(0);
5630        }
5631        msg.extend_from_slice(&bit_len.to_be_bytes());
5632
5633        for block in msg.chunks(64) {
5634            let mut w = [0u32; 64];
5635            for i in 0..16 {
5636                w[i] = u32::from_be_bytes([
5637                    block[i * 4],
5638                    block[i * 4 + 1],
5639                    block[i * 4 + 2],
5640                    block[i * 4 + 3],
5641                ]);
5642            }
5643            for i in 16..64 {
5644                let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3);
5645                let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10);
5646                w[i] = w[i - 16]
5647                    .wrapping_add(s0)
5648                    .wrapping_add(w[i - 7])
5649                    .wrapping_add(s1);
5650            }
5651            let mut a = h;
5652            for i in 0..64 {
5653                let s1 = a[4].rotate_right(6) ^ a[4].rotate_right(11) ^ a[4].rotate_right(25);
5654                let ch = (a[4] & a[5]) ^ ((!a[4]) & a[6]);
5655                let t1 = a[7]
5656                    .wrapping_add(s1)
5657                    .wrapping_add(ch)
5658                    .wrapping_add(K[i])
5659                    .wrapping_add(w[i]);
5660                let s0 = a[0].rotate_right(2) ^ a[0].rotate_right(13) ^ a[0].rotate_right(22);
5661                let maj = (a[0] & a[1]) ^ (a[0] & a[2]) ^ (a[1] & a[2]);
5662                let t2 = s0.wrapping_add(maj);
5663                a[7] = a[6];
5664                a[6] = a[5];
5665                a[5] = a[4];
5666                a[4] = a[3].wrapping_add(t1);
5667                a[3] = a[2];
5668                a[2] = a[1];
5669                a[1] = a[0];
5670                a[0] = t1.wrapping_add(t2);
5671            }
5672            for i in 0..8 {
5673                h[i] = h[i].wrapping_add(a[i]);
5674            }
5675        }
5676
5677        let mut out = [0u8; 32];
5678        for (i, word) in h.iter().enumerate() {
5679            out[i * 4..i * 4 + 4].copy_from_slice(&word.to_be_bytes());
5680        }
5681        out
5682    }
5683
5684    #[cfg(test)]
5685    mod tests {
5686        use super::*;
5687
5688        #[test]
5689        fn sha256_known_vector() {
5690            let d = sha256(b"abc");
5691            let hex: String = d.iter().map(|b| format!("{b:02x}")).collect();
5692            assert_eq!(
5693                hex,
5694                "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
5695            );
5696        }
5697
5698        #[test]
5699        fn hmac_known_vector() {
5700            let mac = hmac_sha256_hex(b"Jefe", b"what do ya want for nothing?");
5701            assert_eq!(
5702                mac,
5703                "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843"
5704            );
5705        }
5706
5707        #[test]
5708        fn sign_verify_round_trips() {
5709            let secret = "test-secret";
5710            let sid = "9f2c-opaque-session-id";
5711            let cookie = sign_session(sid, secret);
5712            let pair = cookie.split(';').next().unwrap().to_string();
5713            let mut headers = HeaderMap::new();
5714            headers.insert(axum::http::header::COOKIE, pair.parse().unwrap());
5715            assert_eq!(verify_session(&headers, secret).as_deref(), Some(sid));
5716            // Wrong secret → rejected (an attacker without the HMAC key can't forge).
5717            assert!(verify_session(&headers, "other-secret").is_none());
5718        }
5719
5720        #[test]
5721        fn forged_and_tampered_cookies_are_rejected() {
5722            let secret = "test-secret";
5723
5724            // 1. A fully forged cookie: attacker knows a victim's DID/sid but not
5725            //    the secret, so an arbitrary signature must not verify.
5726            let forged = format!(
5727                "{SESSION_COOKIE}={}.{}",
5728                b64url_encode(b"attacker-chosen-sid"),
5729                "deadbeef".repeat(8) // 64 hex chars, wrong sig
5730            );
5731            let mut headers = HeaderMap::new();
5732            headers.insert(axum::http::header::COOKIE, forged.parse().unwrap());
5733            assert!(verify_session(&headers, secret).is_none());
5734
5735            // 2. A tampered cookie: take a VALID cookie and mutate the sid while
5736            //    keeping the original signature — must not verify.
5737            let cookie = sign_session("real-sid", secret);
5738            let pair = cookie.split(';').next().unwrap();
5739            let (_b64, sig) = pair.split_once('=').unwrap().1.split_once('.').unwrap();
5740            let tampered = format!(
5741                "{SESSION_COOKIE}={}.{}",
5742                b64url_encode(b"different-sid"),
5743                sig
5744            );
5745            let mut headers2 = HeaderMap::new();
5746            headers2.insert(axum::http::header::COOKIE, tampered.parse().unwrap());
5747            assert!(verify_session(&headers2, secret).is_none());
5748        }
5749
5750        #[test]
5751        fn b64url_round_trips() {
5752            for s in ["did:plc:abc", "", "a", "ab", "abc", "abcd"] {
5753                let enc = b64url_encode(s.as_bytes());
5754                assert_eq!(b64url_decode(&enc).unwrap(), s.as_bytes());
5755            }
5756        }
5757    }
5758}
5759
5760// ---------------------------------------------------------------------------
5761// Small store helpers local to the web layer
5762// ---------------------------------------------------------------------------
5763
5764/// Fetch a single cached entry by id — SCOPED to `did`'s subscriptions.
5765///
5766/// Returns `None` (→ 404 at the handler) if the entry does not exist OR if
5767/// `did` does not subscribe to its feed. This is the per-DID read gate for the
5768/// `GET /entries/:id` reader and the htmx row rebuild: the shared cache is
5769/// deduped by URL, but no DID can read another DID's cached article.
5770///
5771/// **The only `SELECT e.*` left, and deliberately so.** This is the one surface
5772/// that renders `content_html`, and it fetches exactly one row. The list views
5773/// go through [`store::list_entries`], which is both paged and body-free — see
5774/// [`store::EntryListRow`] for why they had to stop sharing this projection.
5775async fn get_entry_by_id(
5776    pool: &store::Pool,
5777    did: &str,
5778    id: i64,
5779) -> anyhow::Result<Option<store::Entry>> {
5780    let entry = sqlx::query_as::<_, store::Entry>(
5781        r#"
5782        SELECT e.* FROM entries e
5783        WHERE e.id = ?2
5784          AND EXISTS (
5785              SELECT 1 FROM sub_ref sr
5786              WHERE sr.did = ?1 AND sr.feed_id = e.feed_id
5787          )
5788        "#,
5789    )
5790    .bind(did)
5791    .bind(id)
5792    .fetch_optional(pool)
5793    .await?;
5794    Ok(entry)
5795}
5796
5797/// Whether `entry_id` is marked read for `did` (absent state row = unread).
5798async fn entry_is_read(pool: &store::Pool, did: &str, entry_id: i64) -> anyhow::Result<bool> {
5799    let read: Option<bool> =
5800        sqlx::query_scalar("SELECT read FROM entry_state WHERE did = ?1 AND entry_id = ?2")
5801            .bind(did)
5802            .bind(entry_id)
5803            .fetch_optional(pool)
5804            .await?
5805            .flatten();
5806    Ok(read.unwrap_or(false))
5807}
5808
5809/// Whether `entry_id` is starred for `did` (absent state row = not starred).
5810async fn entry_is_starred(pool: &store::Pool, did: &str, entry_id: i64) -> anyhow::Result<bool> {
5811    let starred: Option<bool> =
5812        sqlx::query_scalar("SELECT starred FROM entry_state WHERE did = ?1 AND entry_id = ?2")
5813            .bind(did)
5814            .bind(entry_id)
5815            .fetch_optional(pool)
5816            .await?
5817            .flatten();
5818    Ok(starred.unwrap_or(false))
5819}
5820
5821/// Feed display title for one entry's feed id (via a single lookup).
5822async fn feed_title_by_entry(pool: &store::Pool, feed_id: i64) -> String {
5823    match sqlx::query_as::<_, store::Feed>("SELECT * FROM feeds WHERE id = ?1")
5824        .bind(feed_id)
5825        .fetch_optional(pool)
5826        .await
5827    {
5828        Ok(Some(f)) => display_title(f.title.as_deref(), &f.url),
5829        _ => String::new(),
5830    }
5831}
5832
5833/// Rebuild an [`EntryRow`] for an htmx swap after a read/star toggle. `read` may
5834/// be forced (mark-read path) or looked up (`None` — star path).
5835async fn build_entry_row(
5836    pool: &store::Pool,
5837    did: &str,
5838    id: i64,
5839    read: Option<bool>,
5840) -> anyhow::Result<Option<EntryRow>> {
5841    let entry = match get_entry_by_id(pool, did, id).await? {
5842        Some(e) => e,
5843        None => return Ok(None),
5844    };
5845    let read = match read {
5846        Some(r) => r,
5847        None => entry_is_read(pool, did, id).await?,
5848    };
5849    let starred = entry_is_starred(pool, did, id).await?;
5850    Ok(Some(EntryRow {
5851        id: entry.id,
5852        title: entry
5853            .title
5854            .clone()
5855            .filter(|t| !t.trim().is_empty())
5856            .unwrap_or_else(|| "(untitled)".to_string()),
5857        feed_title: feed_title_by_entry(pool, entry.feed_id).await,
5858        published: display_date(entry.published.as_deref()),
5859        read,
5860        starred,
5861        link: SafeLink::entry(id, ""),
5862        cached: true,
5863        rkey: String::new(),
5864    }))
5865}
5866
5867/// RFC3339 "now" (UTC) — shared by handlers that stamp/compare timestamps.
5868fn now_rfc3339() -> String {
5869    chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
5870}
5871
5872#[cfg(test)]
5873mod tests {
5874    use super::*;
5875
5876    #[test]
5877    fn qenc_encodes_reserved() {
5878        assert_eq!(qenc("a b"), "a%20b");
5879        assert_eq!(
5880            qenc("https://example.com/feed.xml"),
5881            "https%3A%2F%2Fexample.com%2Ffeed.xml"
5882        );
5883        assert_eq!(
5884            qenc("at://did:plc:x/c/r"),
5885            "at%3A%2F%2Fdid%3Aplc%3Ax%2Fc%2Fr"
5886        );
5887        // Unreserved chars pass through untouched.
5888        assert_eq!(qenc("A-Za-z0-9-_.~"), "A-Za-z0-9-_.~");
5889    }
5890
5891    #[test]
5892    fn folder_uri_shape() {
5893        assert_eq!(
5894            folder_uri("did:plc:abc", "3kfolder"),
5895            "at://did:plc:abc/community.lexicon.rss.folder/3kfolder"
5896        );
5897    }
5898
5899    // -- public-feeds-only: private/paid feeds are refused --------------------
5900
5901    #[test]
5902    fn private_feeds_are_classified_private_across_providers() {
5903        // The add + OPML paths both gate on this classifier; assert it flags a
5904        // spread of paid providers (newsletters + private podcasts) and the
5905        // generic credential-in-URL shapes.
5906        for url in [
5907            "https://author.substack.com/feed/private/deadbeefcafe1234",
5908            "https://www.patreon.com/rss/author?auth=Zm9vYmFyc2VjcmV0dG9rZW4",
5909            "https://blog.ghost.io/rss/?uuid=1f2e3d4c-5b6a-7089-90ab-cdef01234567",
5910            "https://feeds.supportingcast.fm/show/abcdef0123456789abcdef01",
5911            "https://example.com/feed?token=Zm9vYmFyc2VjcmV0",
5912            "https://user:pass@example.com/feed",
5913        ] {
5914            assert!(
5915                feed::classify_feed_privacy(url).is_private(),
5916                "expected private: {url}"
5917            );
5918        }
5919    }
5920
5921    #[test]
5922    fn public_feeds_stay_public() {
5923        for url in [
5924            "https://author.substack.com/feed",
5925            "https://wordpress.example.com/feed/",
5926            "https://example.com/rss.xml",
5927            "https://example.org/atom.xml",
5928            // YouTube channel/playlist RSS is fully public — must not false-block.
5929            "https://www.youtube.com/feeds/videos.xml?channel_id=UC-lHJZR3Gqxm24_Vd_AJ5Yw",
5930            "https://www.youtube.com/feeds/videos.xml?playlist_id=PLFgquLnL59alCl_2TQvOiD5Vgm1",
5931        ] {
5932            assert!(
5933                !feed::classify_feed_privacy(url).is_private(),
5934                "expected public: {url}"
5935            );
5936        }
5937    }
5938
5939    #[test]
5940    fn private_feed_label_is_public_safe_host_only() {
5941        // The OPML skip report must never echo the secret path/query, only the host.
5942        let label =
5943            private_feed_label("https://author.substack.com/feed/private/deadbeefcafe1234token");
5944        assert_eq!(label, "author.substack.com");
5945        assert!(!label.contains("deadbeefcafe1234token"));
5946        assert!(!label.contains("/private/"));
5947        // An unparseable URL degrades to a generic label.
5948        assert_eq!(private_feed_label("not a url"), "a private feed");
5949    }
5950
5951    #[test]
5952    fn refusal_message_promises_nothing_stored() {
5953        assert!(PRIVATE_FEED_REFUSAL.contains("not saved or sent anywhere"));
5954        assert!(PRIVATE_FEED_REFUSAL.contains("public feeds"));
5955    }
5956
5957    #[test]
5958    fn scope_query_preserves_context() {
5959        let q = EntryQuery {
5960            feed: Some("https://example.com/feed.xml".to_string()),
5961            folder: None,
5962            view: Some("all".to_string()),
5963        };
5964        let s = scope_query(&q);
5965        assert!(s.contains("feed=https%3A%2F%2Fexample.com%2Ffeed.xml"));
5966        assert!(s.contains("view=all"));
5967
5968        // Default view is omitted.
5969        let q2 = EntryQuery {
5970            feed: None,
5971            folder: None,
5972            view: Some("unread".to_string()),
5973        };
5974        assert_eq!(scope_query(&q2), "");
5975    }
5976
5977    // -- closed-beta invite gate + rate-limit + cache-control ------------------
5978
5979    use axum::body::Body;
5980    use axum::http::Request;
5981    use tower::ServiceExt; // for `oneshot`
5982
5983    /// Build an [`AppState`] over a fresh in-memory DB, seeding the given admin
5984    /// DIDs (via ALLOWED_DIDS → ensure_seed) and a fixed cookie secret so tests
5985    /// can forge matching cookies.
5986    async fn test_state(allowed: &[&str]) -> AppState {
5987        let db = store::init_url("sqlite::memory:").await.unwrap();
5988        let dids: Vec<String> = allowed.iter().map(|s| s.to_string()).collect();
5989        store::ensure_seed(&db, &dids).await.unwrap();
5990        let config = Config {
5991            allowed_dids: dids,
5992            cookie_secret: "test-cookie-secret-000".to_string(),
5993            beta_cap: 3,
5994            ..Config::default()
5995        };
5996        AppState::new(config, db).unwrap()
5997    }
5998
5999    /// A `Cookie` header carrying a valid signed session for `sid` (the sid is
6000    /// looked up in the registry, so create the session first).
6001    fn session_cookie(state: &AppState, did: &str, handle: Option<&str>) -> String {
6002        let sid = state.sessions.create(Session {
6003            did: did.to_string(),
6004            handle: handle.map(str::to_string),
6005        });
6006        let sc = cookie::sign_session(&sid, &state.config.cookie_secret);
6007        sc.split(';').next().unwrap().to_string()
6008    }
6009
6010    /// The bucket map is bounded by COUNT, not only by idle time. An hour is a
6011    /// long time to accept distinct source IPs on two unauthenticated guarded
6012    /// routes.
6013    #[test]
6014    fn the_rate_limit_map_is_bounded() {
6015        let rl = RateLimiter::shared();
6016        let now = Instant::now();
6017        for i in 0..(MAX_RATE_BUCKETS + 2_000) {
6018            // Distinct IPv6 addresses, all "seen" at increasing times so the LRU
6019            // ordering below is well-defined.
6020            let ip: IpAddr = format!("2001:db8::{i:x}").parse().unwrap();
6021            rl.check_at(ip, now + Duration::from_millis(i as u64));
6022        }
6023        let len = rl.inner.lock().unwrap().buckets.len();
6024        assert!(
6025            len <= MAX_RATE_BUCKETS,
6026            "the rate-limit map grew to {len}, past its {MAX_RATE_BUCKETS} cap"
6027        );
6028    }
6029
6030    /// Eviction must not hand a throttled attacker a fresh burst.
6031    ///
6032    /// The bound is LRU, so the one bucket an attacker can never evict is their
6033    /// own — it is the most recently touched thing in the map. If this inverted,
6034    /// the size cap would become a rate-limit bypass: spray addresses until the
6035    /// map overflows, then resume.
6036    #[test]
6037    fn flooding_the_map_does_not_reset_the_flooders_own_bucket() {
6038        let rl = RateLimiter::shared();
6039        let base = Instant::now();
6040        let attacker: IpAddr = "203.0.113.7".parse().unwrap();
6041        // Nanosecond steps: enough to keep the LRU ordering strictly increasing,
6042        // far too little for `RATE_REFILL_PER_SEC` to hand back a token. A
6043        // millisecond step made the whole flood take a second, and the refill —
6044        // working correctly — then looked exactly like an eviction bypass.
6045        let at = |n: u64| base + Duration::from_nanos(n);
6046
6047        // Spend the burst. `RATE_BURST` allowed, then refused.
6048        for i in 0..(RATE_BURST as u64) {
6049            assert!(rl.check_at(attacker, at(i)));
6050        }
6051        assert!(
6052            !rl.check_at(attacker, at(RATE_BURST as u64)),
6053            "burst was not exhausted; the rest of this test proves nothing"
6054        );
6055
6056        // Now overflow the map from other addresses, interleaving the attacker
6057        // so their bucket stays hot — the realistic shape of the attack.
6058        for i in 0..(MAX_RATE_BUCKETS + 2_000) {
6059            let t = at(100 + i as u64 * 2);
6060            let ip: IpAddr = format!("2001:db8:1::{i:x}").parse().unwrap();
6061            rl.check_at(ip, t);
6062            assert!(
6063                !rl.check_at(attacker, t),
6064                "the attacker got a token back after evictions at i={i}"
6065            );
6066        }
6067    }
6068
6069    /// The idle sweep is amortised, not per-request. It used to be an O(n) scan
6070    /// of the whole map on every guarded request, on one shared core.
6071    #[test]
6072    fn the_idle_sweep_does_not_run_on_every_request() {
6073        let rl = RateLimiter::shared();
6074        let start = Instant::now();
6075        let a: IpAddr = "198.51.100.1".parse().unwrap();
6076        let b: IpAddr = "198.51.100.2".parse().unwrap();
6077
6078        rl.check_at(a, start);
6079        // `b` arrives an hour later: `a` is now idle past `RATE_IDLE_EVICT`, but
6080        // the sweep interval has elapsed too, so this request does sweep it.
6081        rl.check_at(b, start + RATE_IDLE_EVICT + Duration::from_secs(1));
6082        assert!(
6083            !rl.inner.lock().unwrap().buckets.contains_key(&a),
6084            "an idle bucket survived a sweep that was due"
6085        );
6086
6087        // A second request moments later must NOT re-sweep — `b` is still there,
6088        // and the recorded sweep time must not have moved.
6089        let before = rl.inner.lock().unwrap().last_sweep;
6090        rl.check_at(b, start + RATE_IDLE_EVICT + Duration::from_secs(2));
6091        assert_eq!(
6092            rl.inner.lock().unwrap().last_sweep,
6093            before,
6094            "the sweep ran again within the interval"
6095        );
6096    }
6097
6098    #[test]
6099    fn rate_limited_paths_match_expected() {
6100        use axum::http::Method;
6101        assert!(is_rate_limited_path("/login", &Method::GET));
6102        assert!(is_rate_limited_path("/login", &Method::POST));
6103        assert!(is_rate_limited_path("/beta/redeem", &Method::POST));
6104        assert!(is_rate_limited_path("/subscriptions", &Method::POST));
6105        assert!(is_rate_limited_path("/opml", &Method::POST));
6106        assert!(is_rate_limited_path("/read-all", &Method::POST));
6107        assert!(is_rate_limited_path("/admin/invites", &Method::POST));
6108        assert!(is_rate_limited_path("/entries/42/read", &Method::POST));
6109        assert!(is_rate_limited_path("/entries/42/star", &Method::POST));
6110        // Read-only navigation is NOT limited.
6111        assert!(!is_rate_limited_path("/", &Method::GET));
6112        assert!(!is_rate_limited_path("/about", &Method::GET));
6113        assert!(!is_rate_limited_path("/entries/42", &Method::GET));
6114        assert!(!is_rate_limited_path("/login", &Method::HEAD));
6115    }
6116
6117    #[test]
6118    fn rate_limiter_allows_burst_then_429s() {
6119        let rl = RateLimiter::shared();
6120        let ip: IpAddr = "203.0.113.7".parse().unwrap();
6121        // The full burst passes.
6122        for _ in 0..(RATE_BURST as usize) {
6123            assert!(rl.check(ip));
6124        }
6125        // The next one (no time elapsed → no refill) is rejected.
6126        assert!(!rl.check(ip));
6127        // A different IP has its own bucket.
6128        let ip2: IpAddr = "203.0.113.8".parse().unwrap();
6129        assert!(rl.check(ip2));
6130    }
6131
6132    #[test]
6133    fn client_ip_ignores_spoofed_xff_without_trusted_header() {
6134        // With NO trusted header configured, a client-supplied X-Forwarded-For
6135        // must be ignored entirely — the limiter keys on the real socket peer,
6136        // so an attacker can't mint a fresh bucket per forged XFF value.
6137        let mut h = HeaderMap::new();
6138        h.insert("x-forwarded-for", "198.51.100.9, 10.0.0.1".parse().unwrap());
6139        let sock: SocketAddr = "203.0.113.55:1234".parse().unwrap();
6140        assert_eq!(
6141            client_ip(&h, Some(&sock), None),
6142            Some("203.0.113.55".parse().unwrap()),
6143            "spoofed XFF must not override the socket peer"
6144        );
6145    }
6146
6147    #[test]
6148    fn client_ip_uses_trusted_header_last_hop() {
6149        // With a trusted proxy header configured, the client IP comes from THAT
6150        // header (the proxy overwrites any client copy). On a comma list we take
6151        // the RIGHT-most hop — the one the trusted proxy appended — so a
6152        // client-forged left-most value is ignored.
6153        let sock: SocketAddr = "10.0.0.1:1234".parse().unwrap();
6154
6155        let mut h = HeaderMap::new();
6156        h.insert("fly-client-ip", "198.51.100.9".parse().unwrap());
6157        assert_eq!(
6158            client_ip(&h, Some(&sock), Some("fly-client-ip")),
6159            Some("198.51.100.9".parse().unwrap())
6160        );
6161
6162        // Attacker prepends a forged hop; the trusted proxy appends the real one.
6163        let mut h2 = HeaderMap::new();
6164        h2.insert("x-forwarded-for", "1.2.3.4, 198.51.100.9".parse().unwrap());
6165        assert_eq!(
6166            client_ip(&h2, Some(&sock), Some("x-forwarded-for")),
6167            Some("198.51.100.9".parse().unwrap()),
6168            "must take the right-most (trusted) hop, not the forged left-most"
6169        );
6170
6171        // Trusted header absent → fall back to the socket peer.
6172        let h3 = HeaderMap::new();
6173        assert_eq!(
6174            client_ip(&h3, Some(&sock), Some("fly-client-ip")),
6175            Some("10.0.0.1".parse().unwrap())
6176        );
6177    }
6178
6179    #[test]
6180    fn invite_cookie_round_trips_and_rejects_tamper() {
6181        let secret = "test-cookie-secret-000";
6182        let sc = sign_invite("FEATHER-ABCDWXYZ", secret);
6183        let pair = sc.split(';').next().unwrap();
6184        let mut h = HeaderMap::new();
6185        h.insert(header::COOKIE, pair.parse().unwrap());
6186        assert_eq!(
6187            invite_cookie_code(&h, secret).as_deref(),
6188            Some("FEATHER-ABCDWXYZ")
6189        );
6190        // Wrong secret → rejected.
6191        assert!(invite_cookie_code(&h, "other").is_none());
6192    }
6193
6194    #[tokio::test]
6195    async fn preflight_valid_expired_and_full() {
6196        let state = test_state(&["did:plc:admin"]).await;
6197        // A minted, active code preflights OK.
6198        let code = store::mint_code(&state.db, "did:plc:admin", 3600)
6199            .await
6200            .unwrap();
6201        assert!(preflight_code(&state, &code).await.is_ok());
6202
6203        // A code whose expiry is in the past preflights as Expired. (mint_code
6204        // clamps negative ttl to 0, so back-date the row directly for a
6205        // deterministic past expiry.)
6206        let expired = store::mint_code(&state.db, "did:plc:admin", 3600)
6207            .await
6208            .unwrap();
6209        sqlx::query("UPDATE invite_codes SET expires_at = ?1 WHERE code = ?2")
6210            .bind(chrono::Utc::now().timestamp() - 3600)
6211            .bind(&expired)
6212            .execute(&state.db)
6213            .await
6214            .unwrap();
6215        assert_eq!(
6216            preflight_code(&state, &expired).await,
6217            Err(store::RedeemError::Expired)
6218        );
6219
6220        // Unknown code → NotFound.
6221        assert_eq!(
6222            preflight_code(&state, "FEATHER-NOPENOPE").await,
6223            Err(store::RedeemError::NotFound)
6224        );
6225
6226        // Fill to cap (cap=3; the admin seed already took 1 seat) then preflight
6227        // must report CapacityFull.
6228        store::grant_access(&state.db, "did:plc:b", None, "admin", None)
6229            .await
6230            .unwrap();
6231        store::grant_access(&state.db, "did:plc:c", None, "admin", None)
6232            .await
6233            .unwrap();
6234        assert_eq!(store::count_beta_access(&state.db).await.unwrap(), 3);
6235        assert_eq!(
6236            preflight_code(&state, &code).await,
6237            Err(store::RedeemError::CapacityFull)
6238        );
6239    }
6240
6241    // -- Bot claim link + shared-secret mint ---------------------------------
6242
6243    /// A test state with a configured bot secret (so `/bot/claims` is live).
6244    async fn bot_state(bot_secret: &str) -> AppState {
6245        let db = store::init_url("sqlite::memory:").await.unwrap();
6246        store::ensure_seed(&db, &["did:plc:admin".to_string()])
6247            .await
6248            .unwrap();
6249        let config = Config {
6250            allowed_dids: vec!["did:plc:admin".to_string()],
6251            cookie_secret: "test-cookie-secret-000".to_string(),
6252            beta_cap: 3,
6253            bot_secret: Some(bot_secret.to_string()),
6254            public_url: "https://feather-reader.com".to_string(),
6255            ..Config::default()
6256        };
6257        AppState::new(config, db).unwrap()
6258    }
6259
6260    #[test]
6261    fn claim_token_round_trips_and_rejects_tamper() {
6262        let secret = "test-cookie-secret-000";
6263        let token = sign_claim_token("FEATHER-ABCDWXYZ", secret);
6264        // No cookie framing — a bare URL-safe token.
6265        assert!(!token.contains(';'));
6266        assert_eq!(
6267            claim_token_code(&token, secret).as_deref(),
6268            Some("FEATHER-ABCDWXYZ")
6269        );
6270        // Wrong secret → rejected.
6271        assert!(claim_token_code(&token, "other").is_none());
6272        // Tampered token → rejected.
6273        let mut bad = token.clone();
6274        bad.push('x');
6275        assert!(claim_token_code(&bad, secret).is_none());
6276        // The token is NOT confidential: it is `b64url(code).<sig>`, so the code is
6277        // only base64-obscured (not verbatim, but TRIVIALLY decodable — anyone can
6278        // recover it WITHOUT the secret). The security is single-use + HMAC
6279        // integrity + rate-limit, not secrecy of the code. Assert the code half is
6280        // publicly decodable (a plain base64url decode, no secret involved).
6281        let (b64, _sig) = token.split_once('.').expect("token is b64.sig");
6282        assert_eq!(
6283            test_b64url_decode(b64).as_deref(),
6284            Some("FEATHER-ABCDWXYZ".as_bytes()),
6285            "the code half of the token is plain base64url, decodable by anyone"
6286        );
6287    }
6288
6289    /// Minimal URL-safe base64 (no padding) decoder for the test above, proving the
6290    /// claim token's code half needs NO secret to recover (it is not confidential).
6291    fn test_b64url_decode(input: &str) -> Option<Vec<u8>> {
6292        fn val(c: u8) -> Option<u32> {
6293            match c {
6294                b'A'..=b'Z' => Some((c - b'A') as u32),
6295                b'a'..=b'z' => Some((c - b'a' + 26) as u32),
6296                b'0'..=b'9' => Some((c - b'0' + 52) as u32),
6297                b'-' => Some(62),
6298                b'_' => Some(63),
6299                _ => None,
6300            }
6301        }
6302        let mut out = Vec::with_capacity(input.len() / 4 * 3);
6303        for chunk in input.as_bytes().chunks(4) {
6304            let mut n = 0u32;
6305            let mut bits = 0;
6306            for &c in chunk {
6307                n = (n << 6) | val(c)?;
6308                bits += 6;
6309            }
6310            let bytes = bits / 8;
6311            n <<= 24 - bits;
6312            for i in 0..bytes {
6313                out.push((n >> (16 - i * 8)) as u8);
6314            }
6315        }
6316        Some(out)
6317    }
6318
6319    #[tokio::test]
6320    async fn bot_mint_then_claim_grants_a_seat() {
6321        let state = bot_state("bot-secret-abcdef").await;
6322        let app = router(state.clone());
6323
6324        // 1. Mint a claim via the shared-secret endpoint.
6325        let resp = app
6326            .clone()
6327            .oneshot(
6328                Request::builder()
6329                    .method("POST")
6330                    .uri("/bot/claims")
6331                    .header("x-bot-secret", "bot-secret-abcdef")
6332                    .body(Body::empty())
6333                    .unwrap(),
6334            )
6335            .await
6336            .unwrap();
6337        assert_eq!(resp.status(), StatusCode::OK);
6338        let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
6339            .await
6340            .unwrap();
6341        let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
6342        let token = json["token"].as_str().unwrap().to_string();
6343        let url = json["url"].as_str().unwrap();
6344        assert!(url.starts_with("https://feather-reader.com/claim?t="));
6345        // The raw code is returned for the bot's records but not embedded in url.
6346        assert!(json["code"].as_str().unwrap().starts_with("FEATHER-"));
6347        assert!(!url.contains("FEATHER-"));
6348
6349        // 2. Follow the claim link → reserves the invite cookie + redirects to /login.
6350        let resp = app
6351            .clone()
6352            .oneshot(
6353                Request::builder()
6354                    .method("GET")
6355                    .uri(format!("/claim?t={}", qenc(&token)))
6356                    .body(Body::empty())
6357                    .unwrap(),
6358            )
6359            .await
6360            .unwrap();
6361        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
6362        assert_eq!(resp.headers().get(header::LOCATION).unwrap(), "/login");
6363        let set_cookie = resp
6364            .headers()
6365            .get(header::SET_COOKIE)
6366            .unwrap()
6367            .to_str()
6368            .unwrap();
6369        assert!(set_cookie.starts_with(INVITE_COOKIE), "{set_cookie}");
6370
6371        // 3. The reserved cookie carries the same code the token wrapped, and
6372        //    redeeming it (the callback's machinery) grants a seat.
6373        let code = claim_token_code(&token, &state.config.cookie_secret).unwrap();
6374        let out = store::redeem_code(
6375            &state.db,
6376            &code,
6377            "did:plc:follower",
6378            None,
6379            state.config.beta_cap,
6380        )
6381        .await
6382        .unwrap();
6383        assert_eq!(out, Ok(()));
6384        assert!(store::has_beta_access(&state.db, "did:plc:follower")
6385            .await
6386            .unwrap());
6387    }
6388
6389    #[tokio::test]
6390    async fn claim_with_invalid_token_bounces() {
6391        let state = bot_state("bot-secret-abcdef").await;
6392        let app = router(state);
6393        let resp = app
6394            .oneshot(
6395                Request::builder()
6396                    .method("GET")
6397                    .uri("/claim?t=not-a-real-token")
6398                    .body(Body::empty())
6399                    .unwrap(),
6400            )
6401            .await
6402            .unwrap();
6403        // Renders the invite page (200), NOT a redirect to /login.
6404        assert_eq!(resp.status(), StatusCode::OK);
6405    }
6406
6407    #[tokio::test]
6408    async fn claim_with_used_token_is_refused() {
6409        let state = bot_state("bot-secret-abcdef").await;
6410        // Mint a code + wrap it, then redeem it out from under the token.
6411        let code = store::mint_code(&state.db, "did:plc:admin", 3600)
6412            .await
6413            .unwrap();
6414        let token = sign_claim_token(&code, &state.config.cookie_secret);
6415        store::redeem_code(
6416            &state.db,
6417            &code,
6418            "did:plc:someone",
6419            None,
6420            state.config.beta_cap,
6421        )
6422        .await
6423        .unwrap()
6424        .unwrap();
6425        let app = router(state);
6426        let resp = app
6427            .oneshot(
6428                Request::builder()
6429                    .method("GET")
6430                    .uri(format!("/claim?t={}", qenc(&token)))
6431                    .body(Body::empty())
6432                    .unwrap(),
6433            )
6434            .await
6435            .unwrap();
6436        // A used code → the invite page (AlreadyRedeemed), not a fresh reservation.
6437        assert_eq!(resp.status(), StatusCode::OK);
6438        assert!(resp.headers().get(header::SET_COOKIE).is_none());
6439    }
6440
6441    #[tokio::test]
6442    async fn bot_claims_rejects_bad_and_missing_secret() {
6443        let state = bot_state("bot-secret-abcdef").await;
6444        let app = router(state);
6445        // Wrong secret.
6446        let resp = app
6447            .clone()
6448            .oneshot(
6449                Request::builder()
6450                    .method("POST")
6451                    .uri("/bot/claims")
6452                    .header("x-bot-secret", "wrong")
6453                    .body(Body::empty())
6454                    .unwrap(),
6455            )
6456            .await
6457            .unwrap();
6458        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
6459        // Missing secret.
6460        let resp = app
6461            .oneshot(
6462                Request::builder()
6463                    .method("POST")
6464                    .uri("/bot/claims")
6465                    .body(Body::empty())
6466                    .unwrap(),
6467            )
6468            .await
6469            .unwrap();
6470        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
6471    }
6472
6473    #[tokio::test]
6474    async fn bot_claims_disabled_when_secret_unset() {
6475        // test_state configures NO bot secret → the endpoint is off (503).
6476        let state = test_state(&["did:plc:admin"]).await;
6477        let app = router(state);
6478        let resp = app
6479            .oneshot(
6480                Request::builder()
6481                    .method("POST")
6482                    .uri("/bot/claims")
6483                    .header("x-bot-secret", "anything")
6484                    .body(Body::empty())
6485                    .unwrap(),
6486            )
6487            .await
6488            .unwrap();
6489        assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
6490    }
6491
6492    #[tokio::test]
6493    async fn bot_claims_refuses_at_capacity() {
6494        let state = bot_state("bot-secret-abcdef").await;
6495        // cap=3, admin seed took 1 seat. Grant 2 more to fill it.
6496        store::grant_access(&state.db, "did:plc:b", None, "admin", None)
6497            .await
6498            .unwrap();
6499        store::grant_access(&state.db, "did:plc:c", None, "admin", None)
6500            .await
6501            .unwrap();
6502        assert_eq!(store::count_beta_access(&state.db).await.unwrap(), 3);
6503        let app = router(state);
6504        let resp = app
6505            .oneshot(
6506                Request::builder()
6507                    .method("POST")
6508                    .uri("/bot/claims")
6509                    .header("x-bot-secret", "bot-secret-abcdef")
6510                    .body(Body::empty())
6511                    .unwrap(),
6512            )
6513            .await
6514            .unwrap();
6515        assert_eq!(resp.status(), StatusCode::CONFLICT);
6516        let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
6517            .await
6518            .unwrap();
6519        assert!(String::from_utf8_lossy(&bytes).contains("full"));
6520    }
6521
6522    #[tokio::test]
6523    async fn bot_claims_counts_outstanding_codes_against_cap() {
6524        let state = bot_state("bot-secret-abcdef").await;
6525        // cap=3, admin seed = 1 seat. Two outstanding active codes = 3 committed.
6526        store::mint_code(&state.db, "did:plc:admin", 3600)
6527            .await
6528            .unwrap();
6529        store::mint_code(&state.db, "did:plc:admin", 3600)
6530            .await
6531            .unwrap();
6532        let app = router(state);
6533        let resp = app
6534            .oneshot(
6535                Request::builder()
6536                    .method("POST")
6537                    .uri("/bot/claims")
6538                    .header("x-bot-secret", "bot-secret-abcdef")
6539                    .body(Body::empty())
6540                    .unwrap(),
6541            )
6542            .await
6543            .unwrap();
6544        // 1 seat + 2 outstanding >= cap 3 → refused even though only 1 real seat used.
6545        assert_eq!(resp.status(), StatusCode::CONFLICT);
6546    }
6547
6548    /// POST /bot/claims with a JSON body carrying the follower DID.
6549    async fn post_bot_claim_for(
6550        app: &axum::Router,
6551        secret: &str,
6552        did: &str,
6553    ) -> (StatusCode, serde_json::Value) {
6554        let resp = app
6555            .clone()
6556            .oneshot(
6557                Request::builder()
6558                    .method("POST")
6559                    .uri("/bot/claims")
6560                    .header("x-bot-secret", secret)
6561                    .header("content-type", "application/json")
6562                    .body(Body::from(format!(
6563                        "{{\"did\":\"{did}\",\"handle\":\"who.test\"}}"
6564                    )))
6565                    .unwrap(),
6566            )
6567            .await
6568            .unwrap();
6569        let status = resp.status();
6570        let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
6571            .await
6572            .unwrap();
6573        let json = if bytes.is_empty() {
6574            serde_json::Value::Null
6575        } else {
6576            serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null)
6577        };
6578        (status, json)
6579    }
6580
6581    #[tokio::test]
6582    async fn bot_claims_returns_already_seated_for_a_member() {
6583        // A DID that already holds beta access must get `already_seated` with NO
6584        // code/url — the bot posts nothing. This is the server-side backstop that
6585        // survives a bot-host state loss (it would otherwise re-mint + re-post).
6586        let state = bot_state("bot-secret-abcdef").await;
6587        store::grant_access(&state.db, "did:plc:member", None, "admin", None)
6588            .await
6589            .unwrap();
6590        let app = router(state.clone());
6591        let (status, json) = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:member").await;
6592        assert_eq!(status, StatusCode::OK);
6593        assert_eq!(json["status"], "already_seated");
6594        assert_eq!(json["code"], "");
6595        assert_eq!(json["url"], "");
6596        // No new invite code was minted for the seated DID.
6597        assert!(store::find_active_code_for_did(&state.db, "did:plc:member")
6598            .await
6599            .unwrap()
6600            .is_none());
6601    }
6602
6603    #[tokio::test]
6604    async fn bot_claims_is_idempotent_per_did_returns_same_code() {
6605        // Two mint requests for the SAME follower DID must return the SAME code
6606        // (the app is authoritative), never a second one — so a bot-host state loss
6607        // re-requesting cannot double-mint or double-post.
6608        let state = bot_state("bot-secret-abcdef").await;
6609        let app = router(state.clone());
6610
6611        let (s1, j1) = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:follower1").await;
6612        assert_eq!(s1, StatusCode::OK);
6613        assert_eq!(j1["status"], "minted");
6614        let code1 = j1["code"].as_str().unwrap().to_string();
6615
6616        let (s2, j2) = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:follower1").await;
6617        assert_eq!(s2, StatusCode::OK);
6618        assert_eq!(j2["status"], "existing");
6619        assert_eq!(j2["code"].as_str().unwrap(), code1, "same code returned");
6620        assert_eq!(j2["url"], j1["url"], "same url returned");
6621
6622        // Exactly ONE active code exists for that DID.
6623        assert_eq!(store::count_active_codes(&state.db).await.unwrap(), 1);
6624    }
6625
6626    #[tokio::test]
6627    async fn bot_claims_records_intended_did_at_mint() {
6628        // A fresh mint records the follower DID so the lookup finds it.
6629        let state = bot_state("bot-secret-abcdef").await;
6630        let app = router(state.clone());
6631        let (status, json) =
6632            post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:follower2").await;
6633        assert_eq!(status, StatusCode::OK);
6634        let code = json["code"].as_str().unwrap();
6635        assert_eq!(
6636            store::find_active_code_for_did(&state.db, "did:plc:follower2")
6637                .await
6638                .unwrap()
6639                .as_deref(),
6640            Some(code)
6641        );
6642    }
6643
6644    #[tokio::test]
6645    async fn bot_claims_concurrent_same_did_never_double_mints() {
6646        // S4: two concurrent /bot/claims for ONE follower DID must not both mint an
6647        // active code. The dedupe check (3b) and the mint are separate statements,
6648        // so a race can slip both past 3b's `Ok(None)`; the partial unique index
6649        // then makes the loser's INSERT conflict, and the handler recovers by
6650        // returning the winner's code (status `existing`) rather than 500-ing.
6651        // Result: exactly ONE active code, and BOTH callers get a usable code.
6652        let state = bot_state("bot-secret-abcdef").await;
6653        let app = router(state.clone());
6654
6655        let a = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:racer");
6656        let b = post_bot_claim_for(&app, "bot-secret-abcdef", "did:plc:racer");
6657        let ((sa, ja), (sb, jb)) = tokio::join!(a, b);
6658
6659        assert_eq!(sa, StatusCode::OK, "first response: {ja:?}");
6660        assert_eq!(sb, StatusCode::OK, "second response: {jb:?}");
6661
6662        // Exactly one active code for the DID — the whole point of the fix.
6663        assert_eq!(
6664            store::count_active_codes(&state.db).await.unwrap(),
6665            1,
6666            "concurrent mints must not create two active codes"
6667        );
6668
6669        // Both callers received the SAME (single) code, and neither got a 500.
6670        let ca = ja["code"].as_str().unwrap_or("");
6671        let cb = jb["code"].as_str().unwrap_or("");
6672        assert!(!ca.is_empty() && !cb.is_empty(), "both must return a code");
6673        assert_eq!(ca, cb, "both callers must get the one minted code");
6674        // One is `minted` (the winner), the other `minted` or `existing` depending
6675        // on interleaving — but never an error status.
6676        for st in [&ja["status"], &jb["status"]] {
6677            let s = st.as_str().unwrap_or("");
6678            assert!(s == "minted" || s == "existing", "unexpected status {s:?}");
6679        }
6680    }
6681
6682    #[tokio::test]
6683    async fn bot_claims_rejects_malformed_json_body() {
6684        let state = bot_state("bot-secret-abcdef").await;
6685        let app = router(state);
6686        let resp = app
6687            .oneshot(
6688                Request::builder()
6689                    .method("POST")
6690                    .uri("/bot/claims")
6691                    .header("x-bot-secret", "bot-secret-abcdef")
6692                    .header("content-type", "application/json")
6693                    .body(Body::from("{not json"))
6694                    .unwrap(),
6695            )
6696            .await
6697            .unwrap();
6698        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
6699    }
6700
6701    #[tokio::test]
6702    async fn favicon_ico_served_at_root() {
6703        // Browsers request bare /favicon.ico regardless of the <link rel="icon">
6704        // tags in <head>; the root route must serve the icon, not 404.
6705        let state = test_state(&[]).await;
6706        let app = router(state);
6707        let resp = app
6708            .oneshot(
6709                Request::builder()
6710                    .uri("/favicon.ico")
6711                    .body(Body::empty())
6712                    .unwrap(),
6713            )
6714            .await
6715            .unwrap();
6716        assert_eq!(resp.status(), StatusCode::OK);
6717        let ct = resp
6718            .headers()
6719            .get(header::CONTENT_TYPE)
6720            .unwrap()
6721            .to_str()
6722            .unwrap();
6723        assert!(
6724            ct.contains("icon") || ct.starts_with("image/"),
6725            "content-type = {ct}"
6726        );
6727    }
6728
6729    #[tokio::test]
6730    async fn login_without_invite_redirects_to_beta_redeem() {
6731        // No allow-list seed, no invite cookie: starting OAuth must be refused.
6732        let state = test_state(&[]).await;
6733        let app = router(state);
6734        let resp = app
6735            .oneshot(
6736                Request::builder()
6737                    .method("POST")
6738                    .uri("/login")
6739                    .header("content-type", "application/x-www-form-urlencoded")
6740                    .body(Body::from("handle=alice.bsky.social"))
6741                    .unwrap(),
6742            )
6743            .await
6744            .unwrap();
6745        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
6746        assert_eq!(
6747            resp.headers().get(header::LOCATION).unwrap(),
6748            "/beta/redeem"
6749        );
6750    }
6751
6752    #[tokio::test]
6753    async fn login_with_valid_invite_cookie_starts_oauth() {
6754        let state = test_state(&[]).await;
6755        let cookie = sign_invite("FEATHER-ABCDWXYZ", &state.config.cookie_secret);
6756        let cookie = cookie.split(';').next().unwrap().to_string();
6757        let app = router(state);
6758        let resp = app
6759            .oneshot(
6760                Request::builder()
6761                    .method("POST")
6762                    .uri("/login")
6763                    .header("content-type", "application/x-www-form-urlencoded")
6764                    .header(header::COOKIE, cookie)
6765                    .body(Body::from("handle=alice.bsky.social"))
6766                    .unwrap(),
6767            )
6768            .await
6769            .unwrap();
6770        // Redirects into the sidecar login (not to /beta/redeem).
6771        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
6772        let loc = resp
6773            .headers()
6774            .get(header::LOCATION)
6775            .unwrap()
6776            .to_str()
6777            .unwrap();
6778        assert!(loc.contains("/login"), "loc = {loc}");
6779        assert_ne!(loc, "/beta/redeem");
6780    }
6781
6782    /// A resolver that always fails — proves the fast paths short-circuit BEFORE
6783    /// any network resolution and that a resolution failure fails closed.
6784    async fn resolver_never(_handle: String) -> Option<String> {
6785        None
6786    }
6787
6788    /// A resolver that maps every handle to `did`.
6789    fn resolver_to(did: &'static str) -> impl FnOnce(String) -> std::future::Ready<Option<String>> {
6790        move |_handle| std::future::ready(Some(did.to_string()))
6791    }
6792
6793    /// Path 3: a cookie-less visitor whose submitted handle resolves to a DID
6794    /// that already holds a seat (the seeded-admin first-login case) passes the
6795    /// gate — no session cookie, no invite code.
6796    #[tokio::test]
6797    async fn may_start_oauth_honors_seat_via_resolved_handle() {
6798        // The seeded admin holds a seat (via ALLOWED_DIDS → ensure_seed) but has
6799        // no cookie on a fresh deploy.
6800        let state = test_state(&["did:plc:admin"]).await;
6801        let headers = HeaderMap::new();
6802        assert!(
6803            may_start_oauth_with(
6804                &state,
6805                &headers,
6806                "admin.example",
6807                resolver_to("did:plc:admin")
6808            )
6809            .await,
6810            "a handle resolving to a seated DID must pass the gate"
6811        );
6812    }
6813
6814    /// Path 3, negative: a handle that resolves to a DID with NO seat is bounced
6815    /// — the anti-abuse intent is preserved (resolution succeeds, seat check
6816    /// fails).
6817    #[tokio::test]
6818    async fn may_start_oauth_bounces_non_member_handle() {
6819        let state = test_state(&["did:plc:admin"]).await;
6820        let headers = HeaderMap::new();
6821        assert!(
6822            !may_start_oauth_with(
6823                &state,
6824                &headers,
6825                "rando.example",
6826                resolver_to("did:plc:rando")
6827            )
6828            .await,
6829            "a resolved DID with no seat must be bounced"
6830        );
6831    }
6832
6833    /// Fail-closed: an unresolvable/malformed handle (resolver returns `None`)
6834    /// bounces gracefully — no panic, no handshake.
6835    #[tokio::test]
6836    async fn may_start_oauth_fails_closed_on_unresolvable_handle() {
6837        let state = test_state(&["did:plc:admin"]).await;
6838        let headers = HeaderMap::new();
6839        assert!(
6840            !may_start_oauth_with(&state, &headers, "not a handle", resolver_never).await,
6841            "an unresolvable handle must fail closed"
6842        );
6843    }
6844
6845    /// The session-cookie fast path admits a seated member WITHOUT calling the
6846    /// resolver (proven by injecting `resolver_never`, which would otherwise
6847    /// bounce).
6848    #[tokio::test]
6849    async fn may_start_oauth_session_cookie_shortcircuits_resolution() {
6850        let state = test_state(&[]).await;
6851        let did = "did:plc:member";
6852        store::grant_access(&state.db, did, Some("member.example"), "test", None)
6853            .await
6854            .unwrap();
6855        let cookie = session_cookie(&state, did, Some("member.example"));
6856        let mut headers = HeaderMap::new();
6857        headers.insert(header::COOKIE, cookie.parse().unwrap());
6858        assert!(
6859            may_start_oauth_with(&state, &headers, "member.example", resolver_never).await,
6860            "a seated session cookie must pass without resolution"
6861        );
6862    }
6863
6864    /// The invite-cookie fast path admits WITHOUT calling the resolver.
6865    #[tokio::test]
6866    async fn may_start_oauth_invite_cookie_shortcircuits_resolution() {
6867        let state = test_state(&[]).await;
6868        let cookie = sign_invite("FEATHER-ABCDWXYZ", &state.config.cookie_secret);
6869        let cookie = cookie.split(';').next().unwrap().to_string();
6870        let mut headers = HeaderMap::new();
6871        headers.insert(header::COOKIE, cookie.parse().unwrap());
6872        assert!(
6873            may_start_oauth_with(&state, &headers, "someone.example", resolver_never).await,
6874            "a valid invite cookie must pass without resolution"
6875        );
6876    }
6877
6878    #[tokio::test]
6879    async fn admin_mint_requires_admin_seed_did() {
6880        let state = test_state(&["did:plc:admin"]).await;
6881        // A non-admin (but beta'd) session is forbidden.
6882        store::grant_access(&state.db, "did:plc:rando", None, "test", None)
6883            .await
6884            .unwrap();
6885        let rando_cookie = session_cookie(&state, "did:plc:rando", None);
6886        // An admin session is allowed.
6887        let admin_cookie = session_cookie(&state, "did:plc:admin", None);
6888        let app = router(state);
6889
6890        let forbidden = app
6891            .clone()
6892            .oneshot(
6893                Request::builder()
6894                    .method("POST")
6895                    .uri("/admin/invites?n=2")
6896                    .header(header::COOKIE, rando_cookie)
6897                    .body(Body::empty())
6898                    .unwrap(),
6899            )
6900            .await
6901            .unwrap();
6902        assert_eq!(forbidden.status(), StatusCode::FORBIDDEN);
6903
6904        let ok = app
6905            .oneshot(
6906                Request::builder()
6907                    .method("POST")
6908                    .uri("/admin/invites?n=2")
6909                    .header(header::COOKIE, admin_cookie)
6910                    .body(Body::empty())
6911                    .unwrap(),
6912            )
6913            .await
6914            .unwrap();
6915        assert_eq!(ok.status(), StatusCode::OK);
6916        let bytes = axum::body::to_bytes(ok.into_body(), 64 * 1024)
6917            .await
6918            .unwrap();
6919        let body = String::from_utf8(bytes.to_vec()).unwrap();
6920        let minted: Vec<&str> = body.lines().filter(|l| !l.is_empty()).collect();
6921        assert_eq!(minted.len(), 2);
6922        assert!(minted.iter().all(|c| c.starts_with("FEATHER-")));
6923    }
6924
6925    #[tokio::test]
6926    async fn admin_mint_unauthenticated_is_401() {
6927        let state = test_state(&["did:plc:admin"]).await;
6928        let app = router(state);
6929        let resp = app
6930            .oneshot(
6931                Request::builder()
6932                    .method("POST")
6933                    .uri("/admin/invites")
6934                    .body(Body::empty())
6935                    .unwrap(),
6936            )
6937            .await
6938            .unwrap();
6939        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
6940    }
6941
6942    /// A state whose `/about` renders the adoption line, seeded with one
6943    /// observation.
6944    async fn adoption_state(repos: i64, truncated: bool) -> AppState {
6945        let db = store::init_url("sqlite::memory:").await.unwrap();
6946        store::record_network_stat(
6947            &db,
6948            &store::NetworkStat {
6949                key: store::ADOPTION_STAT_KEY.to_string(),
6950                source: "https://relay1.us-west.bsky.network".to_string(),
6951                value: repos,
6952                truncated,
6953                observed_at: "2026-08-13T04:05:06Z".to_string(),
6954            },
6955        )
6956        .await
6957        .unwrap();
6958        let config = Config {
6959            cookie_secret: "test-cookie-secret-000".to_string(),
6960            show_adoption: true,
6961            ..Config::default()
6962        };
6963        AppState::new(config, db).unwrap()
6964    }
6965
6966    async fn about_body(state: AppState) -> String {
6967        let resp = router(state)
6968            .oneshot(
6969                Request::builder()
6970                    .uri("/about")
6971                    .body(Body::empty())
6972                    .unwrap(),
6973            )
6974            .await
6975            .unwrap();
6976        assert_eq!(resp.status(), StatusCode::OK);
6977        let bytes = axum::body::to_bytes(resp.into_body(), 256 * 1024)
6978            .await
6979            .unwrap();
6980        String::from_utf8(bytes.to_vec()).unwrap()
6981    }
6982
6983    /// Default config ⇒ the flag is off ⇒ no line, and no query is issued.
6984    #[tokio::test]
6985    async fn about_omits_adoption_line_by_default() {
6986        let state = test_state(&[]).await;
6987        assert!(!state.config.show_adoption);
6988        let body = about_body(state).await;
6989        assert!(
6990            !body.contains("atproto network"),
6991            "the adoption line must not render by default"
6992        );
6993    }
6994
6995    #[tokio::test]
6996    async fn about_renders_adoption_line_when_enabled() {
6997        // **A distinctive count, and asserted IN ITS SENTENCE.**
6998        //
6999        // This used to seed 4 and assert `body.contains("4")`, which the
7000        // colophon's `width="44"` satisfies whatever the count is — so
7001        // hardcoding the rendered number passed. Both halves are needed: a
7002        // digit that does not occur incidentally, and the assertion tied to the
7003        // phrase it belongs to.
7004        let body = about_body(adoption_state(7_318, false).await).await;
7005        // The count and its phrase are on separate template lines, so compare
7006        // against a whitespace-collapsed copy rather than the raw HTML.
7007        let flat = body.split_whitespace().collect::<Vec<_>>().join(" ");
7008        assert!(
7009            flat.contains("7318 accounts on the atproto network hold"),
7010            "the count did not render in its own sentence: {flat}",
7011        );
7012        assert!(
7013            body.contains("accounts on the atproto network hold"),
7014            "{body}"
7015        );
7016        assert!(
7017            body.contains("2026-08-13"),
7018            "the observation date must render"
7019        );
7020        assert!(
7021            body.contains("lower bound"),
7022            "the non-archival caveat must ride along with the number"
7023        );
7024        assert!(
7025            !body.contains("At least"),
7026            "an untruncated count is exact-ish"
7027        );
7028    }
7029
7030    /// A count of one must read as "1 account … holds", not "1 accounts … hold".
7031    #[tokio::test]
7032    async fn about_adoption_line_is_singular_at_one() {
7033        let body = about_body(adoption_state(1, false).await).await;
7034        assert!(
7035            body.contains("account on the atproto network holds"),
7036            "{body}"
7037        );
7038    }
7039
7040    /// A truncated observation is a floor, and must say so.
7041    #[tokio::test]
7042    async fn about_adoption_line_says_at_least_when_truncated() {
7043        let body = about_body(adoption_state(25_000, true).await).await;
7044        assert!(body.contains("At least"), "{body}");
7045    }
7046
7047    /// Flag on but no observation (or a zero) ⇒ 200, no line, no error.
7048    #[tokio::test]
7049    async fn about_omits_line_when_enabled_with_no_observation() {
7050        let db = store::init_url("sqlite::memory:").await.unwrap();
7051        let config = Config {
7052            cookie_secret: "test-cookie-secret-000".to_string(),
7053            show_adoption: true,
7054            ..Config::default()
7055        };
7056        let body = about_body(AppState::new(config, db).unwrap()).await;
7057        assert!(!body.contains("atproto network"));
7058    }
7059
7060    #[tokio::test]
7061    async fn cache_control_public_on_about_no_store_on_authed() {
7062        let state = test_state(&["did:plc:admin"]).await;
7063        let admin_cookie = session_cookie(&state, "did:plc:admin", None);
7064        let app = router(state);
7065
7066        // /about → public, cacheable.
7067        let about = app
7068            .clone()
7069            .oneshot(
7070                Request::builder()
7071                    .uri("/about")
7072                    .body(Body::empty())
7073                    .unwrap(),
7074            )
7075            .await
7076            .unwrap();
7077        assert_eq!(
7078            about.headers().get(header::CACHE_CONTROL).unwrap(),
7079            "public, max-age=300"
7080        );
7081        // The security headers are still intact.
7082        // The VALUE, spelled out here rather than compared to the constant —
7083        // `== CONTENT_SECURITY_POLICY` passes with the constant gutted. This
7084        // used to assert only that the header existed, which a policy of
7085        // `default-src *` satisfies.
7086        assert_eq!(
7087            about.headers()["content-security-policy"],
7088            EXPECTED_CSP,
7089            "the CSP is not the policy the router promises"
7090        );
7091        assert_eq!(about.headers().get("x-frame-options").unwrap(), "DENY");
7092
7093        // /privacy and /terms are static public pages → public, cacheable.
7094        for path in ["/privacy", "/terms"] {
7095            let resp = app
7096                .clone()
7097                .oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
7098                .await
7099                .unwrap();
7100            assert_eq!(resp.status(), StatusCode::OK);
7101            assert_eq!(
7102                resp.headers().get(header::CACHE_CONTROL).unwrap(),
7103                "public, max-age=300",
7104                "{path} should be publicly cacheable"
7105            );
7106            // Security headers apply to these pages too.
7107            assert_eq!(resp.headers()["content-security-policy"], EXPECTED_CSP);
7108            assert_eq!(resp.headers().get("x-frame-options").unwrap(), "DENY");
7109        }
7110
7111        // The bare /login landing → public, cacheable.
7112        let login = app
7113            .clone()
7114            .oneshot(
7115                Request::builder()
7116                    .uri("/login")
7117                    .body(Body::empty())
7118                    .unwrap(),
7119            )
7120            .await
7121            .unwrap();
7122        assert_eq!(
7123            login.headers().get(header::CACHE_CONTROL).unwrap(),
7124            "public, max-age=300"
7125        );
7126
7127        // An authenticated page → no-store.
7128        let home = app
7129            .oneshot(
7130                Request::builder()
7131                    .uri("/")
7132                    .header(header::COOKIE, admin_cookie)
7133                    .body(Body::empty())
7134                    .unwrap(),
7135            )
7136            .await
7137            .unwrap();
7138        assert_eq!(
7139            home.headers().get(header::CACHE_CONTROL).unwrap(),
7140            "no-store"
7141        );
7142    }
7143
7144    #[tokio::test]
7145    async fn beta_redeem_page_renders() {
7146        let state = test_state(&[]).await;
7147        let app = router(state);
7148        let resp = app
7149            .oneshot(
7150                Request::builder()
7151                    .uri("/beta/redeem")
7152                    .body(Body::empty())
7153                    .unwrap(),
7154            )
7155            .await
7156            .unwrap();
7157        assert_eq!(resp.status(), StatusCode::OK);
7158        let bytes = axum::body::to_bytes(resp.into_body(), 256 * 1024)
7159            .await
7160            .unwrap();
7161        let html = String::from_utf8(bytes.to_vec()).unwrap();
7162        assert!(html.contains("Invite code"));
7163        assert!(html.contains("/beta/redeem"));
7164    }
7165
7166    #[tokio::test]
7167    async fn rate_limit_returns_429_after_burst() {
7168        // Configure a trusted proxy header so the limiter keys on the forwarded
7169        // IP (the oneshot harness sets no ConnectInfo socket peer).
7170        let db = store::init_url("sqlite::memory:").await.unwrap();
7171        store::ensure_seed(&db, &[]).await.unwrap();
7172        let config = Config {
7173            cookie_secret: "test-cookie-secret-000".to_string(),
7174            beta_cap: 3,
7175            trusted_ip_header: Some("cf-connecting-ip".to_string()),
7176            ..Config::default()
7177        };
7178        let state = AppState::new(config, db).unwrap();
7179        let app = router(state);
7180        // Hammer POST /beta/redeem past the burst from a single (trusted) IP. The
7181        // handler itself returns 200 (re-render) on a bad code; the limiter is
7182        // what eventually yields 429.
7183        let mut saw_429 = false;
7184        for _ in 0..(RATE_BURST as usize + 5) {
7185            let resp = app
7186                .clone()
7187                .oneshot(
7188                    Request::builder()
7189                        .method("POST")
7190                        .uri("/beta/redeem")
7191                        .header("content-type", "application/x-www-form-urlencoded")
7192                        .header("cf-connecting-ip", "203.0.113.200")
7193                        .body(Body::from("code=FEATHER-NOPENOPE"))
7194                        .unwrap(),
7195                )
7196                .await
7197                .unwrap();
7198            if resp.status() == StatusCode::TOO_MANY_REQUESTS {
7199                saw_429 = true;
7200                break;
7201            }
7202        }
7203        assert!(saw_429, "expected a 429 after exhausting the burst");
7204    }
7205
7206    /// **A forged `X-Forwarded-For` does not key the limiter** — the property
7207    /// the middleware's comment cites this test as proof of.
7208    ///
7209    /// The previous version rotated the forged header and asserted that no
7210    /// request was EVER 429'd. Rotating addresses can never exhaust a per-IP
7211    /// burst, so that assertion held whether the header was trusted or
7212    /// ignored — it passed in the vulnerable configuration too. And with no
7213    /// socket peer the limiter fails open, so nothing could have been keyed on
7214    /// anything. Now one real peer sends `RATE_BURST + 5` requests, each with
7215    /// a DIFFERENT forged header, and the last must be 429: they all landed in
7216    /// the peer's bucket. A limiter keying on the header mints a fresh bucket
7217    /// per request and never trips — which is exactly what the mutation does.
7218    #[tokio::test]
7219    async fn a_forged_forwarded_for_header_does_not_key_the_limiter() {
7220        let state = test_state(&[]).await;
7221        assert!(
7222            state.config.trusted_ip_header.is_none(),
7223            "no proxy header is trusted here"
7224        );
7225        let app = router(state);
7226        let peer = std::net::SocketAddr::from(([203, 0, 113, 7], 40000));
7227        let mut saw_429 = false;
7228        for i in 0..(RATE_BURST as usize + 5) {
7229            let forged = format!("10.9.8.{}", i % 250);
7230            let resp = app
7231                .clone()
7232                .oneshot(
7233                    Request::builder()
7234                        .method("POST")
7235                        .uri("/beta/redeem")
7236                        .header("content-type", "application/x-www-form-urlencoded")
7237                        .header("x-forwarded-for", forged)
7238                        .extension(axum::extract::ConnectInfo(peer))
7239                        .body(Body::from("code=FEATHER-NOPENOPE"))
7240                        .unwrap(),
7241                )
7242                .await
7243                .unwrap();
7244            if resp.status() == StatusCode::TOO_MANY_REQUESTS {
7245                saw_429 = true;
7246                break;
7247            }
7248        }
7249        assert!(
7250            saw_429,
7251            "rotating a forged X-Forwarded-For minted fresh buckets: the limiter is keyed on an attacker-chosen header"
7252        );
7253    }
7254
7255    // -- OPML import body cap (DefaultBodyLimit → 413) -------------------------
7256
7257    /// **A private feed is refused BEFORE it is fetched.** The add path's
7258    /// privacy gate had no test at all — `private_feeds_are_classified_private_
7259    /// across_providers` says "the add + OPML paths both gate on this
7260    /// classifier" and nothing checked either. The gate exists so a
7261    /// token-bearing URL never reaches the network; the assertion that
7262    /// matters is the server's hit count: zero.
7263    #[tokio::test]
7264    async fn subscribing_to_a_private_feed_never_reaches_the_network() {
7265        let did = "did:plc:privateadder";
7266        let state = test_state_with_caps(did, 0, 0).await;
7267        let (base, hits) = crate::net::tests::serve_body_counted(b"<rss/>".to_vec()).await;
7268        let port: u16 = base
7269            .trim_end_matches('/')
7270            .rsplit(':')
7271            .next()
7272            .unwrap()
7273            .parse()
7274            .unwrap();
7275        crate::net::test_host_override(
7276            "private-add.test",
7277            std::net::SocketAddr::from(([127, 0, 0, 1], port)),
7278        );
7279        let cookie = session_cookie(&state, did, None);
7280        let resp = router(state.clone())
7281            .oneshot(
7282                Request::builder()
7283                    .method("POST")
7284                    .uri("/subscriptions")
7285                    .header(header::COOKIE, cookie)
7286                    .header("content-type", "application/x-www-form-urlencoded")
7287                    .body(Body::from(format!(
7288                        "url=http%3A%2F%2Fprivate-add.test%3A{port}%2Ffeed%2Fprivate%2Fdeadbeefcafe1234"
7289                    )))
7290                    .unwrap(),
7291            )
7292            .await
7293            .unwrap();
7294        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
7295        let loc = resp
7296            .headers()
7297            .get(header::LOCATION)
7298            .unwrap()
7299            .to_str()
7300            .unwrap();
7301        assert!(loc.contains("Private"), "not refused as private: {loc}");
7302        assert_eq!(
7303            hits.load(std::sync::atomic::Ordering::SeqCst),
7304            0,
7305            "the private feed was FETCHED before being refused"
7306        );
7307        assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
7308    }
7309
7310    /// **OPML import skips a private feed without storing or publishing it.**
7311    /// The import path does not fetch, so "never fetched" is not the signal
7312    /// here; "never stored, never written to the PDS" is. The batch write's
7313    /// bytes are captured and must not carry the URL.
7314    #[tokio::test]
7315    async fn opml_import_skips_a_private_feed_without_storing_or_publishing_it() {
7316        let did = "did:plc:renamer4";
7317        let (sidecar, bodies) = spawn_logging_sidecar().await;
7318        let state = test_state_with_sidecar(&[did], &sidecar).await;
7319        let tokened = "https://www.patreon.com/rss/author?auth=Zm9vYmFyc2VjcmV0dG9rZW4";
7320        let opml = format!(
7321            "<?xml version=\"1.0\"?>\n<opml version=\"2.0\"><head><title>t</title></head><body>\n\
7322             <outline type=\"rss\" text=\"Public\" xmlUrl=\"https://public.example/feed.xml\"/>\n\
7323             <outline type=\"rss\" text=\"Paid\" xmlUrl=\"{tokened}\"/>\n\
7324             </body></opml>"
7325        );
7326        let (ct, body) = opml_multipart(opml.as_bytes());
7327        let cookie = session_cookie(&state, did, None);
7328        let resp = router(state.clone())
7329            .oneshot(
7330                Request::builder()
7331                    .method("POST")
7332                    .uri("/opml")
7333                    .header(header::COOKIE, cookie)
7334                    .header("content-type", ct)
7335                    .body(Body::from(body))
7336                    .unwrap(),
7337            )
7338            .await
7339            .unwrap();
7340        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
7341        let loc = resp
7342            .headers()
7343            .get(header::LOCATION)
7344            .unwrap()
7345            .to_str()
7346            .unwrap();
7347        assert!(
7348            loc.contains("skipped%20as%20private"),
7349            "not reported as skipped: {loc}"
7350        );
7351        assert!(store::get_feed_by_url(&state.db, tokened)
7352            .await
7353            .unwrap()
7354            .is_none());
7355        let sent = bodies.lock().unwrap().join("\n");
7356        assert!(
7357            sent.contains("public.example"),
7358            "the public feed was not written: {sent}"
7359        );
7360        assert!(
7361            !sent.contains("Zm9vYmFyc2VjcmV0dG9rZW4"),
7362            "the secret was PUBLISHED to the PDS: {sent}"
7363        );
7364    }
7365
7366    /// **`GET /login?handle=` is gated like `POST /login`.** Only the POST was
7367    /// tested; the GET form starts the same handshake and had no test, so
7368    /// deleting its gate left the suite green.
7369    #[tokio::test]
7370    async fn get_login_without_a_seat_is_refused() {
7371        let state = test_state(&[]).await;
7372        let resp = router(state)
7373            .oneshot(
7374                Request::builder()
7375                    .method("GET")
7376                    .uri("/login?handle=alice.bsky.social")
7377                    .body(Body::empty())
7378                    .unwrap(),
7379            )
7380            .await
7381            .unwrap();
7382        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
7383        assert_eq!(
7384            resp.headers().get(header::LOCATION).unwrap(),
7385            "/beta/redeem"
7386        );
7387    }
7388
7389    /// A sidecar fake that answers every request `ok` and records the PATH of
7390    /// each in arrival order, plus every body — for asserting what was sent,
7391    /// and in what order.
7392    async fn spawn_logging_sidecar() -> (String, std::sync::Arc<std::sync::Mutex<Vec<String>>>) {
7393        use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
7394        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7395        let addr = listener.local_addr().unwrap();
7396        let log = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
7397        let sink = log.clone();
7398        tokio::spawn(async move {
7399            loop {
7400                let Ok((mut sock, _)) = listener.accept().await else {
7401                    break;
7402                };
7403                let mut raw: Vec<u8> = Vec::new();
7404                let mut chunk = [0u8; 4096];
7405                let text = loop {
7406                    let Ok(n) = sock.read(&mut chunk).await else {
7407                        break String::new();
7408                    };
7409                    if n == 0 {
7410                        break String::from_utf8_lossy(&raw).to_string();
7411                    }
7412                    raw.extend_from_slice(&chunk[..n]);
7413                    let Some(split) = raw.windows(4).position(|w| w == b"\r\n\r\n") else {
7414                        continue;
7415                    };
7416                    let (head, body) = raw.split_at(split + 4);
7417                    let want = String::from_utf8_lossy(head).lines().find_map(|l| {
7418                        let (k, v) = l.split_once(':')?;
7419                        k.eq_ignore_ascii_case("content-length")
7420                            .then(|| v.trim().parse::<usize>().ok())?
7421                    });
7422                    if want.is_none_or(|w| body.len() >= w) {
7423                        break String::from_utf8_lossy(&raw).to_string();
7424                    }
7425                };
7426                let path = text
7427                    .lines()
7428                    .next()
7429                    .and_then(|l| l.split_whitespace().nth(1))
7430                    .unwrap_or("")
7431                    .to_string();
7432                let body_text = text
7433                    .split_once("\r\n\r\n")
7434                    .map(|(_, b)| b)
7435                    .unwrap_or("")
7436                    .to_string();
7437                sink.lock().unwrap().push(format!("{path} {body_text}"));
7438                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();
7439                let resp = format!(
7440                    "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
7441                    body.len(),
7442                    body
7443                );
7444                let _ = sock.write_all(resp.as_bytes()).await;
7445                let _ = sock.flush().await;
7446            }
7447        });
7448        (format!("http://{addr}"), log)
7449    }
7450
7451    /// **Sign-out flushes dirty read-state BEFORE it revokes — through the
7452    /// route.** The previous version of this test called
7453    /// `flush_before_revoke` and `revoke_everywhere` itself and asserted one
7454    /// flush attempt; its doc claimed deleting the call from the handler
7455    /// "drops that to zero", which was false — the handler was never run.
7456    /// Deleting the call left the suite green: #117 regressing in full, with
7457    /// the test named after it still passing. Now `POST /logout` is driven and
7458    /// the sidecar's log must show a repo write BEFORE the revoke.
7459    #[tokio::test]
7460    async fn signing_out_flushes_before_it_revokes_through_the_route() {
7461        let did = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
7462        let (sidecar, log) = spawn_logging_sidecar().await;
7463        let state = test_state_with_sidecar(&[did], &sidecar).await;
7464        crate::store::upsert_cursor(
7465            &state.db,
7466            &crate::store::ReadCursor {
7467                did: did.to_string(),
7468                feed_url: "https://example.com/feed.xml".into(),
7469                read_through: None,
7470                read_ids: "[\"1\"]".into(),
7471                unread_ids: "[]".into(),
7472                dirty: true,
7473                pds_created: false,
7474                updated_at: "2026-09-13T21:22:40Z".into(),
7475            },
7476        )
7477        .await
7478        .unwrap();
7479        let cookie = session_cookie(&state, did, None);
7480        let resp = router(state.clone())
7481            .oneshot(
7482                Request::builder()
7483                    .method("POST")
7484                    .uri("/logout")
7485                    .header(header::COOKIE, cookie)
7486                    .body(Body::empty())
7487                    .unwrap(),
7488            )
7489            .await
7490            .unwrap();
7491        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
7492
7493        let entries = log.lock().unwrap().clone();
7494        let flush = entries
7495            .iter()
7496            .position(|e| e.starts_with("/internal/repo "));
7497        let revoke = entries
7498            .iter()
7499            .position(|e| e.starts_with("/internal/revoke "));
7500        assert!(revoke.is_some(), "sign-out did not revoke: {entries:?}");
7501        assert!(
7502            flush.is_some(),
7503            "sign-out did not attempt a flush before revoking: {entries:?}"
7504        );
7505        assert!(
7506            flush < revoke,
7507            "the flush arrived AFTER the revoke — no session left to send it with: {entries:?}"
7508        );
7509    }
7510
7511    /// The policy, as a literal: the backstop the router calls "neutralises any
7512    /// XSS that slips past sanitization". `script-src 'self'` and no
7513    /// `'unsafe-inline'` on it are the two clauses that make it one.
7514    const EXPECTED_CSP: &str = "default-src 'self'; \
7515     script-src 'self'; \
7516     style-src 'self' 'unsafe-inline'; \
7517     img-src 'self' https: data:; \
7518     font-src 'self'; \
7519     connect-src 'self'; \
7520     form-action 'self'; \
7521     base-uri 'self'; \
7522     frame-ancestors 'none'; \
7523     object-src 'none'";
7524
7525    /// Build a `multipart/form-data` body carrying a single `file` field whose
7526    /// contents are `payload`, returning `(content_type, body_bytes)`.
7527    fn opml_multipart(payload: &[u8]) -> (String, Vec<u8>) {
7528        let boundary = "----featherreadertestboundary";
7529        let mut body = Vec::new();
7530        body.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
7531        body.extend_from_slice(
7532            b"Content-Disposition: form-data; name=\"file\"; filename=\"feeds.opml\"\r\n",
7533        );
7534        body.extend_from_slice(b"Content-Type: text/x-opml\r\n\r\n");
7535        body.extend_from_slice(payload);
7536        body.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes());
7537        (format!("multipart/form-data; boundary={boundary}"), body)
7538    }
7539
7540    #[tokio::test]
7541    async fn opml_import_oversize_upload_returns_413() {
7542        let state = test_state(&["did:plc:admin"]).await;
7543        let cookie = session_cookie(&state, "did:plc:admin", None);
7544        let app = router(state);
7545
7546        // A payload comfortably above the route cap.
7547        let payload = vec![b'a'; OPML_BODY_LIMIT + 1024];
7548        let (content_type, body) = opml_multipart(&payload);
7549
7550        let resp = app
7551            .oneshot(
7552                Request::builder()
7553                    .method("POST")
7554                    .uri("/opml")
7555                    .header("content-type", content_type)
7556                    .header(header::COOKIE, cookie)
7557                    .body(Body::from(body))
7558                    .unwrap(),
7559            )
7560            .await
7561            .unwrap();
7562        assert_eq!(
7563            resp.status(),
7564            StatusCode::PAYLOAD_TOO_LARGE,
7565            "an over-cap OPML upload must be rejected with 413, not collapsed to 500"
7566        );
7567    }
7568
7569    /// **The route's own cap is what refuses this, not the framework's.**
7570    ///
7571    /// `OPML_BODY_LIMIT` used to equal axum's `DefaultBodyLimit` (2 MiB), so
7572    /// the route's layer was a no-op — deleting it left every test green, and
7573    /// `opml_import_oversize_upload_returns_413` was really testing axum. The
7574    /// limit is 1 MiB now, strictly tighter, and this uploads a payload that
7575    /// sits BETWEEN the two: over ours, under the framework's. Only the
7576    /// route's layer can refuse it — remove the layer and this payload is
7577    /// accepted, which is also what demonstrates the framework's default is
7578    /// the larger of the two.
7579    #[tokio::test]
7580    async fn opml_import_over_the_route_cap_is_refused_below_the_framework_default() {
7581        let state = test_state(&["did:plc:admin"]).await;
7582        let cookie = session_cookie(&state, "did:plc:admin", None);
7583        let app = router(state);
7584
7585        // Between the two ceilings: the framework would accept this.
7586        let payload = vec![b'a'; (OPML_BODY_LIMIT + AXUM_DEFAULT_BODY_LIMIT) / 2];
7587        let (content_type, body) = opml_multipart(&payload);
7588
7589        let resp = app
7590            .oneshot(
7591                Request::builder()
7592                    .method("POST")
7593                    .uri("/opml")
7594                    .header("content-type", content_type)
7595                    .header(header::COOKIE, cookie)
7596                    .body(Body::from(body))
7597                    .unwrap(),
7598            )
7599            .await
7600            .unwrap();
7601        assert_eq!(
7602            resp.status(),
7603            StatusCode::PAYLOAD_TOO_LARGE,
7604            "a payload over the route's cap but under the framework's was accepted — \
7605             the route's own DefaultBodyLimit layer is not doing anything"
7606        );
7607    }
7608
7609    #[tokio::test]
7610    async fn opml_import_under_limit_upload_is_accepted() {
7611        let state = test_state(&["did:plc:admin"]).await;
7612        let cookie = session_cookie(&state, "did:plc:admin", None);
7613        let db = state.db.clone();
7614        let app = router(state);
7615
7616        // A small, valid OPML well under the cap: must be accepted (the handler
7617        // redirects to `/` or a flash), i.e. never 413.
7618        let opml = br#"<?xml version="1.0"?>
7619<opml version="2.0"><body>
7620  <outline text="Example" type="rss" xmlUrl="https://example.com/feed.xml"/>
7621</body></opml>"#;
7622        let (content_type, body) = opml_multipart(opml);
7623
7624        let resp = app
7625            .oneshot(
7626                Request::builder()
7627                    .method("POST")
7628                    .uri("/opml")
7629                    .header("content-type", content_type)
7630                    .header(header::COOKIE, cookie)
7631                    .body(Body::from(body))
7632                    .unwrap(),
7633            )
7634            .await
7635            .unwrap();
7636        // **Assert it was ACCEPTED, not merely that it was not a 413.**
7637        //
7638        // The old assertion was `assert_ne!(status, PAYLOAD_TOO_LARGE)`, which a
7639        // 500 satisfies — so making `import_opml` fail unconditionally left this
7640        // green. Three other OPML tests caught that mutation; the one whose name
7641        // promises to cover the under-cap case did not.
7642        assert_eq!(
7643            resp.status(),
7644            StatusCode::SEE_OTHER,
7645            "an under-cap OPML upload was not accepted (status {})",
7646            resp.status(),
7647        );
7648        // **303 alone is not acceptance.** `import_opml` redirects on several
7649        // FAILURES too — unparseable OPML, zero feeds found, every feed trimmed
7650        // by a cap — so an import that stored nothing satisfied the status check.
7651        let stored: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM feeds WHERE url = ?1")
7652            .bind("https://example.com/feed.xml")
7653            .fetch_one(&db)
7654            .await
7655            .unwrap();
7656        assert_eq!(stored, 1, "the upload was redirected but imported nothing");
7657        let location = resp
7658            .headers()
7659            .get(header::LOCATION)
7660            .and_then(|v| v.to_str().ok())
7661            .unwrap_or_default()
7662            .to_string();
7663        assert!(
7664            !location.starts_with("/login"),
7665            "the import bounced to login instead of being accepted: {location}",
7666        );
7667    }
7668
7669    #[tokio::test]
7670    async fn opml_import_logged_out_redirects_to_login() {
7671        // Logged-out callers are redirected before the body is consumed; assert
7672        // the auth short-circuit rather than a body-cap rejection.
7673        let state = test_state(&["did:plc:admin"]).await;
7674        let app = router(state);
7675
7676        let opml = b"<opml version=\"2.0\"><body></body></opml>";
7677        let (content_type, body) = opml_multipart(opml);
7678
7679        let resp = app
7680            .oneshot(
7681                Request::builder()
7682                    .method("POST")
7683                    .uri("/opml")
7684                    .header("content-type", content_type)
7685                    .body(Body::from(body))
7686                    .unwrap(),
7687            )
7688            .await
7689            .unwrap();
7690        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
7691        assert_eq!(resp.headers().get(header::LOCATION).unwrap(), "/login");
7692    }
7693
7694    // -- delete-my-data (POST /account/delete) --------------------------------
7695
7696    /// A one-shot mock sidecar: binds a loopback port, answers exactly one
7697    /// `POST /internal/revoke` with `{ok:true,…}`, and reports (via the returned
7698    /// channel) the DID it was asked to revoke. Enough to prove the delete
7699    /// handler triggers the sidecar revoke without pulling in an HTTP-mock crate.
7700    async fn spawn_revoke_sidecar() -> (String, tokio::sync::oneshot::Receiver<String>) {
7701        use tokio::io::{AsyncReadExt, AsyncWriteExt};
7702        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7703        let addr = listener.local_addr().unwrap();
7704        let (tx, rx) = tokio::sync::oneshot::channel::<String>();
7705        tokio::spawn(async move {
7706            let (mut sock, _) = listener.accept().await.unwrap();
7707            let mut buf = vec![0u8; 4096];
7708            let n = sock.read(&mut buf).await.unwrap();
7709            let req = String::from_utf8_lossy(&buf[..n]).to_string();
7710            // Pull the DID out of the JSON body (last line of the request).
7711            let did = req
7712                .split("\r\n\r\n")
7713                .nth(1)
7714                .and_then(|body| {
7715                    let v: serde_json::Value = serde_json::from_str(body.trim()).ok()?;
7716                    v.get("did")?.as_str().map(str::to_string)
7717                })
7718                .unwrap_or_default();
7719            let is_revoke = req.starts_with("POST /internal/revoke");
7720            let body = serde_json::json!({
7721                "ok": true, "did": did, "revoked": true, "hadSession": true
7722            })
7723            .to_string();
7724            let resp = format!(
7725                "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
7726                body.len(),
7727                body
7728            );
7729            sock.write_all(resp.as_bytes()).await.unwrap();
7730            sock.flush().await.unwrap();
7731            let _ = tx.send(if is_revoke { did } else { String::new() });
7732        });
7733        (format!("http://{addr}"), rx)
7734    }
7735
7736    /// Build an [`AppState`] whose sidecar points at `sidecar_url`.
7737    async fn test_state_with_sidecar(allowed: &[&str], sidecar_url: &str) -> AppState {
7738        let defaults = Config::default();
7739        test_state_with_sidecar_and(
7740            allowed,
7741            sidecar_url,
7742            defaults.standard_site,
7743            defaults.max_feeds_global,
7744        )
7745        .await
7746    }
7747
7748    /// [`test_state_with_sidecar`] with the standard.site flag and the global
7749    /// feeds ceiling chosen — the two settings the at:// paths branch on.
7750    async fn test_state_with_sidecar_and(
7751        allowed: &[&str],
7752        sidecar_url: &str,
7753        standard_site: bool,
7754        max_feeds_global: i64,
7755    ) -> AppState {
7756        let db = store::init_url("sqlite::memory:").await.unwrap();
7757        let dids: Vec<String> = allowed.iter().map(|s| s.to_string()).collect();
7758        store::ensure_seed(&db, &dids).await.unwrap();
7759        let mut config = Config {
7760            allowed_dids: dids,
7761            cookie_secret: "test-cookie-secret-000".to_string(),
7762            beta_cap: 3,
7763            standard_site,
7764            max_feeds_global,
7765            ..Config::default()
7766        };
7767        config.sidecar.public_url = sidecar_url.to_string();
7768        config.sidecar.internal_url = sidecar_url.to_string();
7769        AppState::new(config, db).unwrap()
7770    }
7771
7772    /// A confirmed `POST /account/delete` purges the caller's local rows, calls
7773    /// the sidecar revoke for that DID, and clears the session cookie.
7774    #[tokio::test]
7775    async fn account_delete_purges_rows_and_triggers_revoke() {
7776        let (sidecar_url, revoke_rx) = spawn_revoke_sidecar().await;
7777        let did = "did:plc:leaver";
7778        let state = test_state_with_sidecar(&[], &sidecar_url).await;
7779
7780        // Seed the DID with local rows across the per-DID tables.
7781        store::grant_access(&state.db, did, Some("leaver.example"), "test", None)
7782            .await
7783            .unwrap();
7784        store::replace_sub_refs(&state.db, did, &[]).await.unwrap();
7785        store::mint_code(&state.db, did, 3600).await.unwrap();
7786        assert!(store::has_beta_access(&state.db, did).await.unwrap());
7787
7788        let cookie = session_cookie(&state, did, Some("leaver.example"));
7789        let app = router(state.clone());
7790
7791        let resp = app
7792            .oneshot(
7793                Request::builder()
7794                    .method("POST")
7795                    .uri("/account/delete")
7796                    .header(header::COOKIE, cookie)
7797                    .header("content-type", "application/x-www-form-urlencoded")
7798                    .body(Body::from("confirm=DELETE"))
7799                    .unwrap(),
7800            )
7801            .await
7802            .unwrap();
7803
7804        // Signed out: redirect to /login with the cookie cleared.
7805        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
7806        assert!(resp
7807            .headers()
7808            .get(header::LOCATION)
7809            .unwrap()
7810            .to_str()
7811            .unwrap()
7812            .starts_with("/login"));
7813        let set_cookie = resp
7814            .headers()
7815            .get(header::SET_COOKIE)
7816            .unwrap()
7817            .to_str()
7818            .unwrap();
7819        assert!(set_cookie.contains("Max-Age=0"), "cookie must be cleared");
7820
7821        // The sidecar revoke was called for exactly this DID.
7822        //
7823        // BOUNDED. A bare `await` here meant a broken `revoke_everywhere` — one
7824        // that simply never called the sidecar — hung this test forever instead
7825        // of failing it: a wedged CI job rather than a red one, which is the
7826        // worse of the two signals because nobody reads it as a defect.
7827        let revoked_did = tokio::time::timeout(std::time::Duration::from_secs(10), revoke_rx)
7828            .await
7829            .expect("the sidecar revoke never fired; revoke_everywhere did not call it")
7830            .unwrap();
7831        assert_eq!(
7832            revoked_did, did,
7833            "sidecar revoke must fire for the caller DID"
7834        );
7835
7836        // Local rows are gone.
7837        assert!(!store::has_beta_access(&state.db, did).await.unwrap());
7838        let codes: i64 =
7839            sqlx::query_scalar("SELECT COUNT(*) FROM invite_codes WHERE creator_did = ?1")
7840                .bind(did)
7841                .fetch_one(&state.db)
7842                .await
7843                .unwrap();
7844        assert_eq!(codes, 0);
7845    }
7846
7847    /// An UN-confirmed `POST /account/delete` (wrong/blank `confirm`) deletes
7848    /// nothing and bounces back to /manage.
7849    #[tokio::test]
7850    async fn account_delete_without_confirm_is_a_noop() {
7851        let did = "did:plc:staying";
7852        let state = test_state(&[]).await;
7853        store::grant_access(&state.db, did, None, "test", None)
7854            .await
7855            .unwrap();
7856        let cookie = session_cookie(&state, did, None);
7857        let app = router(state.clone());
7858
7859        let resp = app
7860            .oneshot(
7861                Request::builder()
7862                    .method("POST")
7863                    .uri("/account/delete")
7864                    .header(header::COOKIE, cookie)
7865                    .header("content-type", "application/x-www-form-urlencoded")
7866                    .body(Body::from("confirm=nope"))
7867                    .unwrap(),
7868            )
7869            .await
7870            .unwrap();
7871
7872        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
7873        assert!(resp
7874            .headers()
7875            .get(header::LOCATION)
7876            .unwrap()
7877            .to_str()
7878            .unwrap()
7879            .starts_with("/manage"));
7880        // Nothing deleted.
7881        assert!(store::has_beta_access(&state.db, did).await.unwrap());
7882    }
7883
7884    /// PDS-outage authorization: when the sidecar is unreachable (as it is in
7885    /// this harness — the default sidecar URL is not served), a DID must STILL
7886    /// be unable to read or mutate an entry in a feed it does not subscribe to.
7887    /// This guards the `resolve_subscriptions` fallback: it must fail CLOSED
7888    /// (serve only the DID's own `sub_ref`), never widen the caller's surface to
7889    /// every cached feed.
7890    #[tokio::test]
7891    async fn pds_outage_does_not_widen_cross_did_access() {
7892        let did_a = "did:plc:aaaa";
7893        let state = test_state(&[]).await;
7894        store::grant_access(&state.db, did_a, None, "test", None)
7895            .await
7896            .unwrap();
7897
7898        // Shared cache: feed_a (A subscribes) + feed_b (A does NOT). An entry
7899        // lives in feed_b — the one A must never touch during the outage.
7900        let feed_a = store::upsert_feed(
7901            &state.db,
7902            &store::NewFeed {
7903                url: "https://a.example/feed.xml".to_string(),
7904                title: Some("A".to_string()),
7905                ..Default::default()
7906            },
7907        )
7908        .await
7909        .unwrap();
7910        let feed_b = store::upsert_feed(
7911            &state.db,
7912            &store::NewFeed {
7913                url: "https://b.example/feed.xml".to_string(),
7914                title: Some("B".to_string()),
7915                ..Default::default()
7916            },
7917        )
7918        .await
7919        .unwrap();
7920        store::insert_entries(
7921            &state.db,
7922            feed_b,
7923            &[store::NewEntry {
7924                guid: "b-1".to_string(),
7925                url: Some("https://b.example/1".to_string()),
7926                title: Some("B one".to_string()),
7927                published: Some("2026-07-11T00:00:00Z".to_string()),
7928                content_html: Some("<p>secret B body</p>".to_string()),
7929                ..Default::default()
7930            }],
7931            0,
7932        )
7933        .await
7934        .unwrap();
7935        // A subscribes ONLY to feed_a.
7936        store::replace_sub_refs(&state.db, did_a, &[feed_a])
7937            .await
7938            .unwrap();
7939        // Read B's entry id via a transient sub_ref, then drop it so only the
7940        // shared cache holds B's entry (no DID subscribes to feed_b anymore).
7941        store::replace_sub_refs(&state.db, "did:plc:bbbb", &[feed_b])
7942            .await
7943            .unwrap();
7944        let b_entry_id = store::entries_for_feed(&state.db, "did:plc:bbbb", feed_b)
7945            .await
7946            .unwrap()[0]
7947            .id;
7948        store::replace_sub_refs(&state.db, "did:plc:bbbb", &[])
7949            .await
7950            .unwrap();
7951
7952        let cookie = session_cookie(&state, did_a, None);
7953        let app = router(state.clone());
7954
7955        // GET /entries/{b} as A → 404 even during the outage.
7956        let get_b = app
7957            .clone()
7958            .oneshot(
7959                Request::builder()
7960                    .method("GET")
7961                    .uri(format!("/entries/{b_entry_id}"))
7962                    .header(header::COOKIE, cookie.clone())
7963                    .body(Body::empty())
7964                    .unwrap(),
7965            )
7966            .await
7967            .unwrap();
7968        assert_eq!(
7969            get_b.status(),
7970            StatusCode::NOT_FOUND,
7971            "A must not read B's entry during a PDS outage"
7972        );
7973
7974        // POST /entries/{b}/read as A → 404, and no entry_state row is written.
7975        let read_b = app
7976            .oneshot(
7977                Request::builder()
7978                    .method("POST")
7979                    .uri(format!("/entries/{b_entry_id}/read"))
7980                    .header(header::COOKIE, cookie)
7981                    .header("content-type", "application/x-www-form-urlencoded")
7982                    .body(Body::from("read=true"))
7983                    .unwrap(),
7984            )
7985            .await
7986            .unwrap();
7987        assert_eq!(
7988            read_b.status(),
7989            StatusCode::NOT_FOUND,
7990            "A must not mark B's entry read during a PDS outage"
7991        );
7992
7993        // The fallback must NOT have widened A's sub_ref to feed_b.
7994        let a_feed_ids: Vec<i64> = sqlx::query_scalar("SELECT feed_id FROM sub_ref WHERE did = ?1")
7995            .bind(did_a)
7996            .fetch_all(&state.db)
7997            .await
7998            .unwrap();
7999        assert_eq!(
8000            a_feed_ids,
8001            vec![feed_a],
8002            "outage fallback must not add feeds A never subscribed to"
8003        );
8004        // And B's entry has zero read-state (A's attempt did not mutate).
8005        let es_count: i64 =
8006            sqlx::query_scalar("SELECT COUNT(*) FROM entry_state WHERE did = ?1 AND entry_id = ?2")
8007                .bind(did_a)
8008                .bind(b_entry_id)
8009                .fetch_one(&state.db)
8010                .await
8011                .unwrap();
8012        assert_eq!(es_count, 0, "no cross-DID mutation during the outage");
8013    }
8014
8015    /// **A logout with nothing to revoke is a SUCCESS — on the arm that had
8016    /// nothing. The other arm is counted separately.**
8017    ///
8018    /// Logout is idempotent, so `NoSession` must record as ok; counting it as an
8019    /// error would make the metric noisy in exactly the case that is fine.
8020    ///
8021    /// But `revoke_everywhere` has TWO arms, and a review found that counting
8022    /// only the rust one let `oauth_revoke` report all-clear while every sidecar
8023    /// revocation failed. For anyone who logged in before the cutover the sidecar
8024    /// store is the only one holding tokens, so the rust arm correctly says
8025    /// NoSession and the metric said nothing was wrong. Both arms are now
8026    /// recorded, distinguished by the backend column — so this test pins the
8027    /// BACKEND as well as the outcome.
8028    #[tokio::test]
8029    async fn a_logout_with_no_session_counts_as_success() {
8030        let did = "did:plc:aaaa";
8031        let state = test_state(&[]).await;
8032        assert!(
8033            state.oauth.is_some(),
8034            "meaningless without an oauth runtime; the revoke arm would be skipped",
8035        );
8036
8037        revoke_everywhere(&state, did).await;
8038        let rows = state.metrics.snapshot();
8039        let find = |b: crate::metrics::Backend| {
8040            rows.iter()
8041                .find(|r| r.op == "oauth_revoke" && r.backend == b)
8042                .unwrap_or_else(|| panic!("no oauth_revoke row for {b:?}"))
8043        };
8044
8045        // Rust arm: nothing stored for this DID, so NoSession -> ok.
8046        let rust = find(crate::metrics::Backend::Rust);
8047        assert_eq!(
8048            rust.stats.err_count, 0,
8049            "NoSession was counted as a failure; logout is idempotent",
8050        );
8051        assert_eq!(rust.stats.ok_count, 1);
8052
8053        // Sidecar arm: unreachable in a test, so it must be recorded as an
8054        // ERROR under its own backend — not silently dropped, and not folded
8055        // into the rust row.
8056        let sidecar = find(crate::metrics::Backend::Sidecar);
8057        assert_eq!(
8058            sidecar.stats.err_count, 1,
8059            "a failed sidecar revoke was not counted",
8060        );
8061    }
8062
8063    /// **`Failed` must count as an error — the half the metric exists for.**
8064    ///
8065    /// A review found this unpinned: replacing the mapping with
8066    /// `let revoke_ok = true;` passed all 682 tests. The only revoke test
8067    /// asserted the `NoSession -> ok` half, so the branch that actually means
8068    /// "the PDS still holds tokens we asked it to drop" was untested.
8069    ///
8070    /// Driven through the same handler, with a session present but the PDS
8071    /// unreachable, so `sign_out_discovering` returns `Failed`.
8072    #[tokio::test]
8073    async fn a_failed_rust_revoke_counts_as_an_error() {
8074        let did = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
8075        let state = test_state(&[]).await;
8076        let runtime = state.oauth.as_deref().expect("oauth runtime");
8077        crate::oauth::store::put_session(
8078            &state.db,
8079            &runtime.codec,
8080            &crate::oauth::store::OAuthSession {
8081                sub: did.into(),
8082                issuer: "https://auth.invalid".into(),
8083                aud: "https://pds.invalid".into(),
8084                dpop_key_jwk: crate::oauth::keys::SigningKey::generate("session-dpop")
8085                    .to_jwk_json()
8086                    .unwrap(),
8087                access_token: "at".into(),
8088                refresh_token: "rt".into(),
8089                token_type: "DPoP".into(),
8090                granted_scope: "atproto".into(),
8091                expires_at: Some(crate::store::now_unix() + 3600),
8092            },
8093        )
8094        .await
8095        .unwrap();
8096
8097        revoke_everywhere(&state, did).await;
8098
8099        let rows = state.metrics.snapshot();
8100        let rust = rows
8101            .iter()
8102            .find(|r| r.op == "oauth_revoke" && r.backend == crate::metrics::Backend::Rust)
8103            .expect("no rust oauth_revoke row");
8104        assert_eq!(
8105            rust.stats.err_count, 1,
8106            "an unreachable PDS must count as a revocation failure",
8107        );
8108        assert_eq!(rust.stats.ok_count, 0);
8109    }
8110
8111    /// **The `href` defence is now carried by the TYPE, not by remembering.**
8112    ///
8113    /// `EntryRow.link` used to be a `String`, and the guard was "call
8114    /// `net::safe_link` before assigning it". Deleting that call left all 679
8115    /// tests passing — a live XSS defence with nothing protecting it.
8116    ///
8117    /// `SafeLink` has no `From<String>` and no public member, so the only way to
8118    /// get foreign input into an `href` is `external`, which does the check
8119    /// itself. This test pins that constructor; the *wiring* is now pinned by
8120    /// the compiler, which is the part a test could never hold down.
8121    ///
8122    /// Note the empty-not-absent behaviour: a rejected URL yields an EMPTY link
8123    /// so the template renders the row WITHOUT an anchor. Dropping the row
8124    /// instead would make the record unremovable, because the un-save button
8125    /// lives on it.
8126    #[test]
8127    fn a_hostile_scheme_cannot_reach_an_href_through_safelink() {
8128        for hostile in [
8129            "javascript:alert(1)",
8130            "JavaScript:alert(1)",
8131            "  javascript:alert(1)",
8132            "data:text/html;base64,PHNjcmlwdD4=",
8133            "vbscript:msgbox(1)",
8134            "file:///etc/passwd",
8135            // Protocol-relative: inherits the page's scheme, so it is an
8136            // off-site link wearing a same-site costume. Carried over from the
8137            // test this one replaces, which was its only unique input.
8138            "//evil.example/path",
8139        ] {
8140            let link = SafeLink::external(hostile);
8141            assert!(
8142                link.is_empty(),
8143                "{hostile:?} produced a non-empty href: {link}",
8144            );
8145            assert!(
8146                !link.to_string().to_ascii_lowercase().contains("script"),
8147                "{hostile:?} leaked into the rendered link",
8148            );
8149        }
8150
8151        // And the other direction: a check that rejects everything would satisfy
8152        // the loop above while breaking every real saved record.
8153        for good in ["https://example.com/a?b=c#d", "http://example.com/"] {
8154            let link = SafeLink::external(good);
8155            assert!(!link.is_empty(), "{good:?} was wrongly rejected");
8156            assert_eq!(link.to_string(), good);
8157        }
8158    }
8159
8160    /// **The WIRING, not the helper — this is the one that catches the real
8161    /// mistake.**
8162    ///
8163    /// `a_hostile_scheme_cannot_reach_an_href_through_safelink` pins what
8164    /// `SafeLink::external` *does*. It cannot pin that the saved-record path
8165    /// *calls* it, and a review proved that gap was live twice over: swapping
8166    /// `external` for the app-path constructor, and constructing the tuple
8167    /// directly, both restored the whole `javascript:` hole with every test
8168    /// green. The type now blocks both — `entry` takes an `i64`, and the field
8169    /// lives in another module — but the wiring deserves a test of its own
8170    /// rather than resting on the shape of a signature.
8171    ///
8172    /// Renders the actual row through the actual handler, from a record whose
8173    /// URL is hostile.
8174    #[tokio::test]
8175    async fn a_saved_record_with_a_hostile_url_renders_no_anchor() {
8176        let did = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
8177        let sidecar = spawn_saved_sidecar("javascript:alert(1)", "Hostile record").await;
8178        let mut state = test_state_with_sidecar(&[did], &sidecar).await;
8179        std::sync::Arc::get_mut(&mut state.config).unwrap().dev_did = Some(did.to_string());
8180
8181        let resp = router(state)
8182            .oneshot(
8183                Request::builder()
8184                    .uri("/?view=starred")
8185                    .body(Body::empty())
8186                    .unwrap(),
8187            )
8188            .await
8189            .unwrap();
8190        assert_eq!(resp.status(), StatusCode::OK);
8191        let body = String::from_utf8(
8192            axum::body::to_bytes(resp.into_body(), usize::MAX)
8193                .await
8194                .unwrap()
8195                .to_vec(),
8196        )
8197        .unwrap();
8198
8199        // Not in an href, and not as the title either — the title falls back to
8200        // the URL for links we DO render, so both paths must withhold it.
8201        assert!(
8202            !body.to_ascii_lowercase().contains("javascript:"),
8203            "the hostile scheme reached the rendered page",
8204        );
8205        // But the row must survive: the un-save button lives on it, so dropping
8206        // the row would make the record unremovable from here.
8207        assert!(
8208            body.contains("unusable link"),
8209            "the row was dropped instead of rendering without an anchor",
8210        );
8211    }
8212
8213    /// **The reader view's two `href`s, through the actual handler.**
8214    ///
8215    /// The sibling above covers the LIST row. `entry.html` has its own pair of
8216    /// `href`s fed by `EntryTemplate.url`, and they were a raw `Option<String>`
8217    /// taken straight off the `entries.url` column — a remote feed's `<link>`.
8218    ///
8219    /// Ingest already scheme-checks that column (`feed.rs`'s `entry_link`), so
8220    /// this was never a live hole. But that guard is procedural and sits a long
8221    /// way from the `href`: it holds only as long as every future writer to
8222    /// `entries.url` remembers to go through `feed.rs`. This test does not
8223    /// depend on it — it writes the hostile URL into the column DIRECTLY, which
8224    /// is precisely the state the ingest check cannot speak for.
8225    ///
8226    /// **Both directions, deliberately.** A fix that renders no link at all
8227    /// satisfies every negative assertion here, and would break every real
8228    /// entry. The second half is what makes the first half mean something.
8229    #[tokio::test]
8230    async fn a_hostile_entry_url_renders_the_reader_without_an_original_link() {
8231        let did = "did:plc:readerhref";
8232        let state = test_state(&[]).await;
8233        store::grant_access(&state.db, did, None, "test", None)
8234            .await
8235            .unwrap();
8236        let feed = store::upsert_feed(
8237            &state.db,
8238            &store::NewFeed {
8239                url: "https://href.example/feed.xml".to_string(),
8240                title: Some("Href".to_string()),
8241                ..Default::default()
8242            },
8243        )
8244        .await
8245        .unwrap();
8246        // Straight into the column, bypassing `feed.rs` — the whole point.
8247        store::insert_entries(
8248            &state.db,
8249            feed,
8250            &[
8251                store::NewEntry {
8252                    guid: "hostile-1".to_string(),
8253                    url: Some("javascript:alert(1)".to_string()),
8254                    title: Some("Hostile entry".to_string()),
8255                    published: Some("2026-07-11T00:00:00Z".to_string()),
8256                    ..Default::default()
8257                },
8258                store::NewEntry {
8259                    guid: "benign-1".to_string(),
8260                    url: Some("https://href.example/post".to_string()),
8261                    title: Some("Benign entry".to_string()),
8262                    published: Some("2026-07-10T00:00:00Z".to_string()),
8263                    ..Default::default()
8264                },
8265            ],
8266            0,
8267        )
8268        .await
8269        .unwrap();
8270        store::replace_sub_refs(&state.db, did, &[feed])
8271            .await
8272            .unwrap();
8273        let rows = store::entries_for_feed(&state.db, did, feed).await.unwrap();
8274        let id_of = |guid: &str| {
8275            rows.iter()
8276                .find(|r| r.guid == guid)
8277                .unwrap_or_else(|| panic!("{guid} was not inserted"))
8278                .id
8279        };
8280
8281        let cookie = session_cookie(&state, did, None);
8282        let app = router(state.clone());
8283
8284        let render = |id: i64| {
8285            let app = app.clone();
8286            let cookie = cookie.clone();
8287            async move {
8288                let resp = app
8289                    .oneshot(
8290                        Request::builder()
8291                            .method("GET")
8292                            .uri(format!("/entries/{id}"))
8293                            .header(header::COOKIE, cookie)
8294                            .body(Body::empty())
8295                            .unwrap(),
8296                    )
8297                    .await
8298                    .unwrap();
8299                assert_eq!(resp.status(), StatusCode::OK);
8300                String::from_utf8(
8301                    axum::body::to_bytes(resp.into_body(), usize::MAX)
8302                        .await
8303                        .unwrap()
8304                        .to_vec(),
8305                )
8306                .unwrap()
8307            }
8308        };
8309
8310        let hostile = render(id_of("hostile-1")).await;
8311        // The reader page for THIS entry actually rendered. Without this the
8312        // three negatives below are satisfied by an empty body.
8313        assert!(
8314            hostile.contains("Hostile entry"),
8315            "the reader did not render the entry: {hostile}",
8316        );
8317        assert!(
8318            !hostile.to_ascii_lowercase().contains("javascript:"),
8319            "the hostile scheme reached the reader page: {hostile}",
8320        );
8321        // Not merely escaped — the template took its no-link branch. Both
8322        // `href`s are gated on the same `Option`, so this covers the byline
8323        // link and the action-bar button together.
8324        assert!(
8325            !hostile.contains("actionbar-open"),
8326            "the action bar rendered an open-original link for a refused URL: {hostile}",
8327        );
8328        assert!(
8329            !hostile.contains("Original \u{2197}"),
8330            "the byline rendered an original link for a refused URL: {hostile}",
8331        );
8332
8333        // The other direction: a legitimate entry still links out, so "render
8334        // nothing" cannot pass as a fix.
8335        let benign = render(id_of("benign-1")).await;
8336        assert!(
8337            benign.contains("Benign entry"),
8338            "the reader did not render the benign entry: {benign}",
8339        );
8340        // BOTH `href`s, counted. The negatives above fire on the action bar
8341        // first, so without this the byline needle `Original \u{2197}` is never
8342        // once observed failing — a misspelled needle would pass forever.
8343        assert_eq!(
8344            benign
8345                .matches(r#"href="https://href.example/post""#)
8346                .count(),
8347            2,
8348            "entry.html has two `href`s for the entry URL — the byline link and \
8349             the action-bar button — and this render produced a different \
8350             number: {benign}",
8351        );
8352        assert!(
8353            benign.contains("actionbar-open"),
8354            "a legitimate entry lost its open-original button: {benign}",
8355        );
8356        assert!(
8357            benign.contains("Original \u{2197}"),
8358            "a legitimate entry lost its byline link: {benign}",
8359        );
8360    }
8361
8362    /// **The outage fallback must not widen what the caller can READ — and the
8363    /// sibling test above can only see what it WRITES.**
8364    ///
8365    /// `pds_outage_does_not_widen_cross_did_access` asserts on `sub_ref` rows and
8366    /// on `entry_state`: the fallback's side effects. But the fail-open it names
8367    /// returns **early**, before `sync_sub_refs` runs, so it touches neither. It
8368    /// leaks through the list it *hands back* — the sidebar and the reader render
8369    /// from that list, so a caller sees another DID's feeds while `sub_ref` stays
8370    /// perfectly honest and every existing assertion stays green.
8371    ///
8372    /// Measured, not assumed: replacing `feeds_for_did(pool, did)` with
8373    /// `due_feeds(pool, "9999-…", 10_000)` over the whole shared cache — the
8374    /// exact historical bug the fallback's comment describes — left **all 663
8375    /// tests passing**. Cross-tenant isolation is the one property this project
8376    /// cannot regress quietly, and nothing observed it.
8377    ///
8378    /// So this asserts on the RETURN VALUE, which is the thing that reaches the
8379    /// user, and it deliberately does not look at `sub_ref` at all — that half is
8380    /// already covered above.
8381    #[tokio::test]
8382    async fn the_outage_fallback_returns_only_the_callers_own_feeds() {
8383        let did_a = "did:plc:aaaa";
8384        let state = test_state(&[]).await;
8385        store::grant_access(&state.db, did_a, None, "test", None)
8386            .await
8387            .unwrap();
8388
8389        let feed_a = store::upsert_feed(
8390            &state.db,
8391            &store::NewFeed {
8392                url: "https://a.example/feed.xml".to_string(),
8393                title: Some("A".to_string()),
8394                ..Default::default()
8395            },
8396        )
8397        .await
8398        .unwrap();
8399        let _feed_b = store::upsert_feed(
8400            &state.db,
8401            &store::NewFeed {
8402                url: "https://b.example/feed.xml".to_string(),
8403                title: Some("B".to_string()),
8404                ..Default::default()
8405            },
8406        )
8407        .await
8408        .unwrap();
8409        // A subscribes ONLY to feed_a. feed_b is in the shared cache and belongs
8410        // to nobody — exactly the row a whole-cache fallback would hand to A.
8411        store::replace_sub_refs(&state.db, did_a, &[feed_a])
8412            .await
8413            .unwrap();
8414
8415        // No sidecar and no PDS are reachable from a test, so
8416        // `list_subscriptions_sorted` fails and this IS the outage path. Assert
8417        // that, rather than assuming it: if the repo ever starts succeeding here,
8418        // this test would silently stop exercising the fallback at all.
8419        assert!(
8420            state.repo().list_subscriptions_sorted(did_a).await.is_err(),
8421            "this test is only meaningful on the outage path; the repo answered",
8422        );
8423
8424        let resolved = resolve_subscriptions(&state, did_a).await;
8425
8426        let urls: Vec<&str> = resolved.iter().map(|r| r.sub.url.as_str()).collect();
8427        assert_eq!(
8428            urls,
8429            vec!["https://a.example/feed.xml"],
8430            "the outage fallback must return the caller's OWN subscriptions only; \
8431             any other feed here is cross-tenant read access granted by an outage",
8432        );
8433    }
8434
8435    /// Build an [`AppState`] over a fresh in-memory DB with explicit feed caps,
8436    /// seeding `did` a beta seat + session-capable state.
8437    async fn test_state_with_caps(
8438        did: &str,
8439        max_subs_per_did: i64,
8440        max_feeds_global: i64,
8441    ) -> AppState {
8442        let db = store::init_url("sqlite::memory:").await.unwrap();
8443        let config = Config {
8444            cookie_secret: "test-cookie-secret-000".to_string(),
8445            beta_cap: 100,
8446            max_subs_per_did,
8447            max_feeds_global,
8448            ..Config::default()
8449        };
8450        store::grant_access(&db, did, None, "test", None)
8451            .await
8452            .unwrap();
8453        AppState::new(config, db).unwrap()
8454    }
8455
8456    /// An OPML document with `n` distinct public feeds.
8457    fn opml_with_feeds(n: usize) -> String {
8458        let mut outlines = String::new();
8459        for i in 0..n {
8460            outlines.push_str(&format!(
8461                "<outline type=\"rss\" text=\"F{i}\" xmlUrl=\"https://f{i}.example/feed.xml\"/>\n"
8462            ));
8463        }
8464        format!(
8465            "<?xml version=\"1.0\"?>\n<opml version=\"2.0\"><head><title>t</title></head><body>\n{outlines}</body></opml>"
8466        )
8467    }
8468
8469    /// OPML bulk import must honour the GLOBAL feeds ceiling: importing more
8470    /// distinct new feeds than the shared cache can hold caches only up to the
8471    /// ceiling — the rest are trimmed. (Regression: the import loop previously
8472    /// bypassed `max_feeds_global` entirely.)
8473    #[tokio::test]
8474    async fn opml_import_enforces_global_feeds_ceiling() {
8475        let did = "did:plc:importer";
8476        // Cap the shared cache at 3 feeds; import 10 distinct new ones.
8477        let state = test_state_with_caps(did, 0, 3).await;
8478        let cookie = session_cookie(&state, did, None);
8479        let (ct, body) = opml_multipart(opml_with_feeds(10).as_bytes());
8480        let app = router(state.clone());
8481
8482        let resp = app
8483            .oneshot(
8484                Request::builder()
8485                    .method("POST")
8486                    .uri("/opml")
8487                    .header(header::COOKIE, cookie)
8488                    .header("content-type", ct)
8489                    .body(Body::from(body))
8490                    .unwrap(),
8491            )
8492            .await
8493            .unwrap();
8494        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8495
8496        let feeds = store::count_feeds(&state.db).await.unwrap();
8497        assert!(
8498            feeds <= 3,
8499            "OPML import blew past the global ceiling: {feeds} feeds cached with cap=3"
8500        );
8501    }
8502
8503    /// **A malformed `at://` on the add path is "not a kind of feed we take",
8504    /// not "private/paid".** The first gate was the privacy classifier, whose
8505    /// at:// arm fails closed as `Private` for anything not a well-formed
8506    /// publication URI — so a typo (`did:plc:TOOSHORT`, a missing rkey) drew
8507    /// the private-feed flash and a "refused private/paid feed" log line. On
8508    /// main the same input reached `resolve_feed_url` and got "Couldn't find a
8509    /// feed". Storability is decided first for an at:// input, with its own
8510    /// message.
8511    #[tokio::test]
8512    async fn a_malformed_at_uri_on_the_add_path_is_refused_as_unsupported_not_private() {
8513        let did = "did:plc:typoist";
8514        let state = test_state_with_caps(did, 0, 0).await;
8515        let cookie = session_cookie(&state, did, None);
8516        for input in [
8517            "at%3A%2F%2Falice.example.com%2Fsite.standard.publication",
8518            "at%3A%2F%2Fdid%3Aplc%3ATOOSHORT%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
8519        ] {
8520            let resp = router(state.clone())
8521                .oneshot(
8522                    Request::builder()
8523                        .method("POST")
8524                        .uri("/subscriptions")
8525                        .header(header::COOKIE, cookie.clone())
8526                        .header("content-type", "application/x-www-form-urlencoded")
8527                        .body(Body::from(format!("url={input}")))
8528                        .unwrap(),
8529                )
8530                .await
8531                .unwrap();
8532            assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8533            let loc = resp
8534                .headers()
8535                .get(header::LOCATION)
8536                .unwrap()
8537                .to_str()
8538                .unwrap();
8539            assert!(
8540                loc.contains("kind%20of%20feed"),
8541                "expected the unsupported-feed flash for {input}, got {loc}"
8542            );
8543            assert!(
8544                !loc.contains("Private"),
8545                "a storability refusal was reported as a privacy one for {input}: {loc}"
8546            );
8547        }
8548        assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
8549    }
8550
8551    /// **An OPML entry this instance cannot store is counted and reported, not
8552    /// silently dropped.** The storability `continue` incremented nothing,
8553    /// while the privacy branch beside it produced a user-visible label — so
8554    /// an OPML exported from a standard.site-enabled instance imported
8555    /// "successfully" with entries missing and no reason given. The reader is
8556    /// told how many, and why.
8557    #[tokio::test]
8558    async fn opml_import_reports_entries_this_instance_cannot_store() {
8559        let did = "did:plc:renamer4";
8560        let (sidecar, _puts) = spawn_rename_sidecar(seeded_subscription()).await;
8561        let state = test_state_with_sidecar(&[did], &sidecar).await;
8562        assert!(!state.config.standard_site);
8563        let opml = format!(
8564            "<?xml version=\"1.0\"?>\n<opml version=\"2.0\"><head><title>t</title></head><body>\n\
8565             <outline type=\"rss\" text=\"Real\" xmlUrl=\"https://real.example/feed.xml\"/>\n\
8566             <outline type=\"rss\" text=\"Pub\" xmlUrl=\"{AT_URI_SUB}\"/>\n\
8567             </body></opml>"
8568        );
8569        let (ct, body) = opml_multipart(opml.as_bytes());
8570        let cookie = session_cookie(&state, did, None);
8571        let resp = router(state.clone())
8572            .oneshot(
8573                Request::builder()
8574                    .method("POST")
8575                    .uri("/opml")
8576                    .header(header::COOKIE, cookie)
8577                    .header("content-type", ct)
8578                    .body(Body::from(body))
8579                    .unwrap(),
8580            )
8581            .await
8582            .unwrap();
8583        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8584        let loc = resp
8585            .headers()
8586            .get(header::LOCATION)
8587            .unwrap()
8588            .to_str()
8589            .unwrap();
8590        assert!(
8591            loc.contains("Imported%201%20feed"),
8592            "unexpected flash: {loc}"
8593        );
8594        assert!(
8595            loc.contains("1%20feed%28s%29%20skipped") && loc.contains("can%20subscribe%20to"),
8596            "the dropped entry was not reported: {loc}"
8597        );
8598        // Reported by count only: the at-URI itself is not echoed back.
8599        assert!(
8600            !loc.contains("site.standard.publication"),
8601            "the URI was echoed: {loc}"
8602        );
8603    }
8604
8605    /// OPML bulk import must honour the PER-DID subscription cap: a DID at its
8606    /// cap imports zero new feeds.
8607    #[tokio::test]
8608    async fn opml_import_enforces_per_did_cap() {
8609        let did = "did:plc:capped";
8610        // Per-DID cap 2, global unlimited. Pre-seed the DID at its cap.
8611        let state = test_state_with_caps(did, 2, 0).await;
8612        let existing_a = store::upsert_feed(
8613            &state.db,
8614            &store::NewFeed {
8615                url: "https://have-a.example/feed.xml".to_string(),
8616                ..Default::default()
8617            },
8618        )
8619        .await
8620        .unwrap();
8621        let existing_b = store::upsert_feed(
8622            &state.db,
8623            &store::NewFeed {
8624                url: "https://have-b.example/feed.xml".to_string(),
8625                ..Default::default()
8626            },
8627        )
8628        .await
8629        .unwrap();
8630        store::replace_sub_refs(&state.db, did, &[existing_a, existing_b])
8631            .await
8632            .unwrap();
8633        let before = store::count_feeds(&state.db).await.unwrap();
8634
8635        let cookie = session_cookie(&state, did, None);
8636        let (ct, body) = opml_multipart(opml_with_feeds(10).as_bytes());
8637        let app = router(state.clone());
8638        let resp = app
8639            .oneshot(
8640                Request::builder()
8641                    .method("POST")
8642                    .uri("/opml")
8643                    .header(header::COOKIE, cookie)
8644                    .header("content-type", ct)
8645                    .body(Body::from(body))
8646                    .unwrap(),
8647            )
8648            .await
8649            .unwrap();
8650        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8651        // Headroom was 0 → no new feeds imported into the shared cache.
8652        let after = store::count_feeds(&state.db).await.unwrap();
8653        assert_eq!(after, before, "over-cap DID imported new feeds anyway");
8654    }
8655
8656    /// Single-add per-DID cap: a DID at its subscription cap is refused before
8657    /// any fetch, with the limit flash.
8658    #[tokio::test]
8659    async fn single_add_enforces_per_did_cap() {
8660        let did = "did:plc:subcapped";
8661        let state = test_state_with_caps(did, 1, 0).await;
8662        let f = store::upsert_feed(
8663            &state.db,
8664            &store::NewFeed {
8665                url: "https://have.example/feed.xml".to_string(),
8666                ..Default::default()
8667            },
8668        )
8669        .await
8670        .unwrap();
8671        store::replace_sub_refs(&state.db, did, &[f]).await.unwrap();
8672        let cookie = session_cookie(&state, did, None);
8673        let app = router(state.clone());
8674        let resp = app
8675            .oneshot(
8676                Request::builder()
8677                    .method("POST")
8678                    .uri("/subscriptions")
8679                    .header(header::COOKIE, cookie)
8680                    .header("content-type", "application/x-www-form-urlencoded")
8681                    .body(Body::from("url=https://another.example/feed.xml"))
8682                    .unwrap(),
8683            )
8684            .await
8685            .unwrap();
8686        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
8687        let loc = resp
8688            .headers()
8689            .get(header::LOCATION)
8690            .unwrap()
8691            .to_str()
8692            .unwrap();
8693        assert!(
8694            loc.contains("Subscription%20limit%20reached"),
8695            "expected sub-limit flash, got {loc}"
8696        );
8697    }
8698
8699    /// `GET /` renders at most one page of rows and offers a way to the rest.
8700    ///
8701    /// The handler used to materialize EVERY unread entry — `SELECT e.*`, no
8702    /// `LIMIT`, article bodies included — and hand the lot to the template. With
8703    /// 250 entries that is the whole list in one response; with a real backlog on
8704    /// a 512 MB box it is the OOM the operator review flagged. Asserts the page
8705    /// is capped, the heading still reports the true total, and page 2 is
8706    /// reachable and disjoint.
8707    #[tokio::test]
8708    async fn the_reader_index_pages_instead_of_rendering_everything() {
8709        let did = "did:plc:pager";
8710        let state = test_state(&[]).await;
8711        store::grant_access(&state.db, did, None, "test", None)
8712            .await
8713            .unwrap();
8714        let feed = store::upsert_feed(
8715            &state.db,
8716            &store::NewFeed {
8717                url: "https://pager.example/feed.xml".to_string(),
8718                title: Some("Pager".to_string()),
8719                ..Default::default()
8720            },
8721        )
8722        .await
8723        .unwrap();
8724        let total = 250_usize;
8725        let entries: Vec<store::NewEntry> = (0..total)
8726            .map(|i| store::NewEntry {
8727                guid: format!("p-{i:04}"),
8728                url: Some(format!("https://pager.example/{i}")),
8729                title: Some(format!("Article {i:04}")),
8730                published: Some(format!("2026-07-{:02}T00:00:00Z", (i % 28) + 1)),
8731                content_html: Some("x".repeat(4_000)),
8732                ..Default::default()
8733            })
8734            .collect();
8735        store::insert_entries(&state.db, feed, &entries, 0)
8736            .await
8737            .unwrap();
8738        store::replace_sub_refs(&state.db, did, &[feed])
8739            .await
8740            .unwrap();
8741
8742        let cookie = session_cookie(&state, did, None);
8743        let app = router(state.clone());
8744        let get = |uri: &str| {
8745            let app = app.clone();
8746            let cookie = cookie.clone();
8747            let uri = uri.to_string();
8748            async move {
8749                let resp = app
8750                    .oneshot(
8751                        Request::builder()
8752                            .uri(uri)
8753                            .header(header::COOKIE, cookie)
8754                            .body(Body::empty())
8755                            .unwrap(),
8756                    )
8757                    .await
8758                    .unwrap();
8759                assert_eq!(resp.status(), StatusCode::OK);
8760                let bytes = axum::body::to_bytes(resp.into_body(), 8 * 1024 * 1024)
8761                    .await
8762                    .unwrap();
8763                String::from_utf8(bytes.to_vec()).unwrap()
8764            }
8765        };
8766
8767        let page1 = get("/").await;
8768        // One `<li class="entry…>` per rendered row. Counting "/entries/" would
8769        // over-count: each row carries several (the link plus the read/star
8770        // forms).
8771        let rows1 = page1.matches("<li class=\"entry").count();
8772        assert!(
8773            rows1 <= ENTRIES_PER_PAGE as usize,
8774            "page 1 rendered {rows1} entry links; the list is unbounded"
8775        );
8776        assert!(
8777            rows1 > 0,
8778            "page 1 rendered nothing at all: the page bound swallowed the list"
8779        );
8780        // The count is the TRUE total, not the page size — otherwise paging
8781        // would quietly relabel a 250-entry backlog as a 100-entry one.
8782        assert!(
8783            page1.contains("250 entries"),
8784            "heading must report the full total, not the page"
8785        );
8786        assert!(
8787            page1.contains("page=2"),
8788            "no way to reach the rest of the list: {}",
8789            &page1[..page1.len().min(400)]
8790        );
8791        // The body never belongs in a list response.
8792        assert!(
8793            !page1.contains(&"x".repeat(4_000)),
8794            "the list response carried an article body"
8795        );
8796
8797        let page2 = get("/?page=2").await;
8798        assert!(
8799            page2.matches("<li class=\"entry").count() > 0,
8800            "page 2 rendered no rows at all"
8801        );
8802        assert!(
8803            page2.contains("page=1") || page2.contains("Newer"),
8804            "page 2 offers no way back"
8805        );
8806        // Disjoint: an article on page 1 must not reappear on page 2.
8807        let first_title = (0..total)
8808            .map(|i| format!("Article {i:04}"))
8809            .find(|t| page1.contains(t))
8810            .expect("page 1 shows at least one titled article");
8811        assert!(
8812            !page2.contains(&first_title),
8813            "{first_title} appears on both pages"
8814        );
8815
8816        // A page past the end must not be a dead end. The empty state renders
8817        // instead of the pager, so an out-of-range page would leave a reader
8818        // with no link back — reachable by typing a number, and reachable
8819        // WITHOUT typing anything by paging to the end and then marking entries
8820        // read, which shrinks the list under the URL already in the address bar.
8821        let past_end = get("/?page=999").await;
8822        assert!(
8823            past_end.matches("<li class=\"entry").count() > 0,
8824            "an out-of-range page rendered nothing and offered no way back"
8825        );
8826        assert!(
8827            past_end.contains("page=2"),
8828            "the clamped page offers no pager"
8829        );
8830    }
8831
8832    /// Reader-view mark-read (a request tagged `X-FR-Reader: 1`) must return the
8833    /// out-of-band action-bar fragment with FRESHLY re-read state so a second
8834    /// keypress reverses the toggle: `hx-swap-oob="outerHTML"` is present, and
8835    /// the hidden `read` input + `aria-pressed` reflect the NEW state. The list
8836    /// view (no reader header) instead swaps the row. This guards the reader OOB
8837    /// toggle wiring, which had no test.
8838    #[tokio::test]
8839    async fn reader_mark_read_returns_oob_actionbar_with_flipped_state() {
8840        let did = "did:plc:reader";
8841        let state = test_state(&[]).await;
8842        store::grant_access(&state.db, did, None, "test", None)
8843            .await
8844            .unwrap();
8845        let feed = store::upsert_feed(
8846            &state.db,
8847            &store::NewFeed {
8848                url: "https://reader.example/feed.xml".to_string(),
8849                title: Some("Reader".to_string()),
8850                ..Default::default()
8851            },
8852        )
8853        .await
8854        .unwrap();
8855        store::insert_entries(
8856            &state.db,
8857            feed,
8858            &[store::NewEntry {
8859                guid: "r-1".to_string(),
8860                url: Some("https://reader.example/1".to_string()),
8861                title: Some("Article".to_string()),
8862                published: Some("2026-07-11T00:00:00Z".to_string()),
8863                content_html: Some("<p>body</p>".to_string()),
8864                ..Default::default()
8865            }],
8866            0,
8867        )
8868        .await
8869        .unwrap();
8870        store::replace_sub_refs(&state.db, did, &[feed])
8871            .await
8872            .unwrap();
8873        let entry_id = store::entries_for_feed(&state.db, did, feed).await.unwrap()[0].id;
8874
8875        let cookie = session_cookie(&state, did, None);
8876        let app = router(state.clone());
8877
8878        // Reader-tagged mark-read → OOB action-bar fragment, entry now READ.
8879        let resp = app
8880            .clone()
8881            .oneshot(
8882                Request::builder()
8883                    .method("POST")
8884                    .uri(format!("/entries/{entry_id}/read"))
8885                    .header(header::COOKIE, cookie.clone())
8886                    .header("HX-Request", "true")
8887                    .header("X-FR-Reader", "1")
8888                    .header("content-type", "application/x-www-form-urlencoded")
8889                    .body(Body::from("read=true"))
8890                    .unwrap(),
8891            )
8892            .await
8893            .unwrap();
8894        assert_eq!(resp.status(), StatusCode::OK);
8895        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
8896            .await
8897            .unwrap();
8898        let html = String::from_utf8(bytes.to_vec()).unwrap();
8899        assert!(
8900            html.contains("hx-swap-oob=\"outerHTML\""),
8901            "reader response must be an OOB swap: {html}"
8902        );
8903        assert!(
8904            html.contains(r#"id="entry-actionbar""#),
8905            "reader response must be the action-bar fragment: {html}"
8906        );
8907        // Now READ: the read button reflects it (aria-pressed=true) and the
8908        // hidden value flips to `false` so the next tap marks it UNREAD.
8909        assert!(
8910            html.contains(r#"aria-pressed="true""#),
8911            "read button must show pressed after marking read: {html}"
8912        );
8913        assert!(
8914            html.contains(r#"name="read" value="false""#),
8915            "hidden read value must flip to false so a second tap reverses: {html}"
8916        );
8917
8918        // A second reader mark-read (submitting the flipped `read=false`) marks
8919        // it UNREAD again — the toggle reverses.
8920        let resp2 = app
8921            .oneshot(
8922                Request::builder()
8923                    .method("POST")
8924                    .uri(format!("/entries/{entry_id}/read"))
8925                    .header(header::COOKIE, cookie)
8926                    .header("HX-Request", "true")
8927                    .header("X-FR-Reader", "1")
8928                    .header("content-type", "application/x-www-form-urlencoded")
8929                    .body(Body::from("read=false"))
8930                    .unwrap(),
8931            )
8932            .await
8933            .unwrap();
8934        assert_eq!(resp2.status(), StatusCode::OK);
8935        let bytes2 = axum::body::to_bytes(resp2.into_body(), 64 * 1024)
8936            .await
8937            .unwrap();
8938        let html2 = String::from_utf8(bytes2.to_vec()).unwrap();
8939        assert!(
8940            html2.contains(r#"aria-pressed="false""#),
8941            "read button must show un-pressed after reversing: {html2}"
8942        );
8943        assert!(
8944            html2.contains(r#"name="read" value="true""#),
8945            "hidden read value must flip back to true: {html2}"
8946        );
8947    }
8948
8949    /// The LIST view (no `X-FR-Reader` header) swaps the row, not the OOB
8950    /// action-bar — the counterpart to the reader-OOB test above.
8951    #[tokio::test]
8952    async fn list_mark_read_returns_row_not_oob_actionbar() {
8953        let did = "did:plc:listv";
8954        let state = test_state(&[]).await;
8955        store::grant_access(&state.db, did, None, "test", None)
8956            .await
8957            .unwrap();
8958        let feed = store::upsert_feed(
8959            &state.db,
8960            &store::NewFeed {
8961                url: "https://list.example/feed.xml".to_string(),
8962                title: Some("List".to_string()),
8963                ..Default::default()
8964            },
8965        )
8966        .await
8967        .unwrap();
8968        store::insert_entries(
8969            &state.db,
8970            feed,
8971            &[store::NewEntry {
8972                guid: "l-1".to_string(),
8973                url: Some("https://list.example/1".to_string()),
8974                title: Some("Article".to_string()),
8975                published: Some("2026-07-11T00:00:00Z".to_string()),
8976                ..Default::default()
8977            }],
8978            0,
8979        )
8980        .await
8981        .unwrap();
8982        store::replace_sub_refs(&state.db, did, &[feed])
8983            .await
8984            .unwrap();
8985        let entry_id = store::entries_for_feed(&state.db, did, feed).await.unwrap()[0].id;
8986
8987        let cookie = session_cookie(&state, did, None);
8988        let app = router(state.clone());
8989
8990        let resp = app
8991            .oneshot(
8992                Request::builder()
8993                    .method("POST")
8994                    .uri(format!("/entries/{entry_id}/read"))
8995                    .header(header::COOKIE, cookie)
8996                    .header("HX-Request", "true")
8997                    .header("content-type", "application/x-www-form-urlencoded")
8998                    .body(Body::from("read=true"))
8999                    .unwrap(),
9000            )
9001            .await
9002            .unwrap();
9003        assert_eq!(resp.status(), StatusCode::OK);
9004        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
9005            .await
9006            .unwrap();
9007        let html = String::from_utf8(bytes.to_vec()).unwrap();
9008        assert!(
9009            !html.contains("hx-swap-oob"),
9010            "list-view response must NOT be an OOB swap: {html}"
9011        );
9012        // **And it must actually BE the row.** The assertion above is satisfied
9013        // by an empty body, or by any response that simply omits the attribute —
9014        // so on its own it pins half a property and the name promises the other
9015        // half.
9016        assert!(
9017            html.contains(&format!("/entries/{entry_id}")),
9018            "the response is not the row for this entry: {html}",
9019        );
9020        assert!(
9021            html.contains("Article"),
9022            "the row rendered without its title: {html}",
9023        );
9024        // **The row comes back carrying read state. That is all this proves.**
9025        //
9026        // It does NOT prove the state was persisted: the handler renders
9027        // `Some(read)` from the form value, so making `mark_read` roll back
9028        // instead of commit fails 11 store tests and leaves this one green.
9029        //
9030        // It does not prove the OVERRIDE either, which an earlier version of
9031        // this comment claimed. Verified: changing the call site to
9032        // `build_entry_row(pool, &did, id, None)` — deleting the override
9033        // wholesale — keeps the whole suite green, because `mark_read` has
9034        // already persisted the same value two lines earlier, so reading it back
9035        // from the database produces an identical row.
9036        //
9037        // Distinguishing the two needs a case where the override and the stored
9038        // state DISAGREE, which this handler never produces: it writes the value
9039        // it then renders. Left as a known gap rather than described as covered.
9040        assert!(
9041            html.contains("is-read"),
9042            "the row came back without the read state it was just given: {html}",
9043        );
9044    }
9045
9046    // -----------------------------------------------------------------------
9047    // Rename parity (POST /subscriptions/{rkey}/rename)
9048    // -----------------------------------------------------------------------
9049
9050    /// **Autodiscovery cannot smuggle a non-http(s) URL into storage.**
9051    ///
9052    /// The add path gates the URL the user *typed*; the URL it *stores* is
9053    /// whatever `resolve_feed_url` returns, which for an HTML page is a
9054    /// publisher-controlled `<link rel="alternate">` href. Two layers stop
9055    /// that: `discover_feed` yields only http(s), and the add path re-checks
9056    /// storability on the resolved URL. This test pins the DISJUNCTION —
9057    /// each layer alone holds it, both removed fails it — driven through the
9058    /// real route against a real local server.
9059    ///
9060    /// **Why the fixture is `ftp://`, not `at://`.** This began as the
9061    /// at-URI bypass test from #164, and it was vacuous twice over. Handle
9062    /// form: once storage became DID-only the privacy classifier refused it
9063    /// at its own gate. DID form: `Url::parse` cannot read it (invalid port
9064    /// — the colons in the DID), so `discover_feed` drops it before either
9065    /// layer exists. An at:// link cannot come out of autodiscovery under
9066    /// ANY mutation of the layers, so no test through this route can pin
9067    /// them with one. `ftp://` reaches both. The at:// case is guaranteed by
9068    /// structure and pinned where it lives: `discover_skips_a_non_http_
9069    /// alternate` and the storability tests in `feed.rs`.
9070    #[tokio::test]
9071    async fn autodiscovery_cannot_smuggle_a_non_http_url_into_storage() {
9072        let did = "did:plc:autodiscovered";
9073        // Access granted, both caps disabled — the only gates left are the
9074        // two under test.
9075        let state = test_state_with_caps(did, 0, 0).await;
9076
9077        let page = r#"<!doctype html><html><head><title>Blog</title>
9078            <link rel="alternate" type="application/rss+xml" href="ftp://files.example/feed.xml">
9079            </head><body>hi</body></html>"#;
9080        let base = crate::net::tests::serve_body(page.as_bytes().to_vec()).await;
9081        let port: u16 = base
9082            .trim_end_matches('/')
9083            .rsplit(':')
9084            .next()
9085            .unwrap()
9086            .parse()
9087            .unwrap();
9088        crate::net::test_host_override(
9089            "autodiscover-ftp.test",
9090            std::net::SocketAddr::from(([127, 0, 0, 1], port)),
9091        );
9092
9093        let cookie = session_cookie(&state, did, None);
9094        let resp = router(state.clone())
9095            .oneshot(
9096                Request::builder()
9097                    .method("POST")
9098                    .uri("/subscriptions")
9099                    .header(header::COOKIE, cookie)
9100                    .header("content-type", "application/x-www-form-urlencoded")
9101                    .body(Body::from(format!(
9102                        "url=http://autodiscover-ftp.test:{port}/"
9103                    )))
9104                    .unwrap(),
9105            )
9106            .await
9107            .unwrap();
9108        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9109        let loc = resp
9110            .headers()
9111            .get(header::LOCATION)
9112            .unwrap()
9113            .to_str()
9114            .unwrap();
9115        assert_ne!(loc, "/login", "the test never reached the add path");
9116        assert_ne!(loc, "/", "the subscribe succeeded");
9117
9118        assert_eq!(
9119            store::count_feeds(&state.db).await.unwrap(),
9120            0,
9121            "a non-http(s) URL from autodiscovery was stored"
9122        );
9123        assert_eq!(
9124            store::count_subscriptions_for_did(&state.db, did)
9125                .await
9126                .unwrap(),
9127            0
9128        );
9129    }
9130
9131    /// A rename that points at a BRAND-NEW feed URL while the shared cache is at
9132    /// its global ceiling must be refused (capacity flash) and must NOT insert a
9133    /// new `feeds` row — parity with add_subscription's global-cap guard, so a
9134    /// rename loop can't inflate the shared cache past the cap.
9135    #[tokio::test]
9136    async fn rename_to_new_url_refused_at_global_feeds_cap() {
9137        let did = "did:plc:renamer4";
9138        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
9139        // Global cap 1; pre-fill it with one feed so headroom is 0.
9140        let state = test_state_with_sidecar_and(&[did], &sidecar, false, 1).await;
9141        store::upsert_feed(
9142            &state.db,
9143            &store::NewFeed {
9144                url: "https://existing.example/feed.xml".to_string(),
9145                ..Default::default()
9146            },
9147        )
9148        .await
9149        .unwrap();
9150        let before = store::count_feeds(&state.db).await.unwrap();
9151        assert_eq!(before, 1);
9152
9153        let cookie = session_cookie(&state, did, None);
9154        let resp = router(state.clone())
9155            .oneshot(
9156                Request::builder()
9157                    .method("POST")
9158                    .uri("/subscriptions/rk-keep/rename")
9159                    .header(header::COOKIE, cookie)
9160                    .header("content-type", "application/x-www-form-urlencoded")
9161                    // A URL not in the cache → would be a NEW feeds row.
9162                    .body(Body::from(
9163                        "url=https://brand-new.example/feed.xml&title=Renamed",
9164                    ))
9165                    .unwrap(),
9166            )
9167            .await
9168            .unwrap();
9169        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9170        let loc = resp
9171            .headers()
9172            .get(header::LOCATION)
9173            .unwrap()
9174            .to_str()
9175            .unwrap();
9176        assert!(
9177            loc.contains("feed%20capacity"),
9178            "expected the feed-capacity flash, got {loc}"
9179        );
9180        // No new feeds row was inserted, and nothing reached the PDS.
9181        assert_eq!(store::count_feeds(&state.db).await.unwrap(), before);
9182        assert!(
9183            puts.lock().unwrap().is_empty(),
9184            "a refused repoint reached the PDS"
9185        );
9186    }
9187
9188    /// A repoint to an EXISTING URL adds no row, so it is allowed even at the
9189    /// global cap (only new URLs are gated) — the other half of the guard.
9190    ///
9191    /// On the sidecar fake, so "allowed" means the put actually happened: the
9192    /// earlier harness had no sidecar, and this passed on a "could not reach
9193    /// your PDS" flash that merely was not the capacity one.
9194    #[tokio::test]
9195    async fn rename_to_existing_url_allowed_at_global_feeds_cap() {
9196        let did = "did:plc:renamer4";
9197        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
9198        let state = test_state_with_sidecar_and(&[did], &sidecar, false, 1).await;
9199        store::upsert_feed(
9200            &state.db,
9201            &store::NewFeed {
9202                url: "https://existing.example/feed.xml".to_string(),
9203                ..Default::default()
9204            },
9205        )
9206        .await
9207        .unwrap();
9208        let before = store::count_feeds(&state.db).await.unwrap();
9209
9210        let cookie = session_cookie(&state, did, None);
9211        let resp = router(state.clone())
9212            .oneshot(
9213                Request::builder()
9214                    .method("POST")
9215                    .uri("/subscriptions/rk-keep/rename")
9216                    .header(header::COOKIE, cookie)
9217                    .header("content-type", "application/x-www-form-urlencoded")
9218                    .body(Body::from(
9219                        "url=https://existing.example/feed.xml&title=Retitled",
9220                    ))
9221                    .unwrap(),
9222            )
9223            .await
9224            .unwrap();
9225        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9226        let loc = resp
9227            .headers()
9228            .get(header::LOCATION)
9229            .unwrap()
9230            .to_str()
9231            .unwrap();
9232        assert_eq!(loc, "/", "the repoint to a cached URL was refused: {loc}");
9233        assert_eq!(
9234            puts.lock().unwrap().len(),
9235            1,
9236            "the repoint did not reach the PDS"
9237        );
9238        assert_eq!(store::count_feeds(&state.db).await.unwrap(), before);
9239    }
9240
9241    /// A rename with a blank URL writes nothing anywhere.
9242    #[tokio::test]
9243    async fn rename_with_blank_url_writes_nothing() {
9244        let did = "did:plc:renamer3";
9245        let state = test_state_with_caps(did, 0, 0).await;
9246        let before = store::count_feeds(&state.db).await.unwrap();
9247        assert_eq!(before, 0);
9248
9249        let cookie = session_cookie(&state, did, None);
9250        let app = router(state.clone());
9251        let resp = app
9252            .oneshot(
9253                Request::builder()
9254                    .method("POST")
9255                    .uri("/subscriptions/rkey123/rename")
9256                    .header(header::COOKIE, cookie)
9257                    .header("content-type", "application/x-www-form-urlencoded")
9258                    // Whitespace-only URL trims to empty.
9259                    .body(Body::from("url=%20%20&title=Nope"))
9260                    .unwrap(),
9261            )
9262            .await
9263            .unwrap();
9264        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9265        assert_eq!(
9266            resp.headers()
9267                .get(header::LOCATION)
9268                .unwrap()
9269                .to_str()
9270                .unwrap(),
9271            "/",
9272        );
9273        // Nothing was cached.
9274        assert_eq!(
9275            store::count_feeds(&state.db).await.unwrap(),
9276            0,
9277            "blank-URL rename wrote a junk feeds row"
9278        );
9279    }
9280
9281    /// A sidecar mock that serves ONE existing subscription record and captures
9282    /// every `put` body a rename produces.
9283    ///
9284    /// **Reads to `content-length` rather than taking one `read`.** A single
9285    /// read gets whatever one segment carried; if the head and body land
9286    /// separately the capture holds no record and every field assertion below
9287    /// passes for the wrong reason. Each captured body must also mention the
9288    /// collection, so an empty capture fails loudly instead of quietly.
9289    async fn spawn_rename_sidecar(
9290        existing: serde_json::Value,
9291    ) -> (String, std::sync::Arc<std::sync::Mutex<Vec<String>>>) {
9292        use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
9293        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9294        let addr = listener.local_addr().unwrap();
9295        let puts = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
9296        let sink = puts.clone();
9297        tokio::spawn(async move {
9298            loop {
9299                let Ok((mut sock, _)) = listener.accept().await else {
9300                    break;
9301                };
9302                let mut raw: Vec<u8> = Vec::new();
9303                let mut chunk = [0u8; 4096];
9304                let body_text = loop {
9305                    let Ok(n) = sock.read(&mut chunk).await else {
9306                        break String::new();
9307                    };
9308                    if n == 0 {
9309                        break String::from_utf8_lossy(&raw).to_string();
9310                    }
9311                    raw.extend_from_slice(&chunk[..n]);
9312                    let Some(split) = raw.windows(4).position(|w| w == b"\r\n\r\n") else {
9313                        continue;
9314                    };
9315                    let (head, body) = raw.split_at(split + 4);
9316                    let want = String::from_utf8_lossy(head).lines().find_map(|l| {
9317                        let (k, v) = l.split_once(':')?;
9318                        k.eq_ignore_ascii_case("content-length")
9319                            .then(|| v.trim().parse::<usize>().ok())?
9320                    });
9321                    if want.is_none_or(|want| body.len() >= want) {
9322                        break String::from_utf8_lossy(body).to_string();
9323                    }
9324                };
9325
9326                // `"action":"put"` is the rename write; anything else is the read.
9327                let is_put = body_text.contains("\"action\":\"put\"");
9328                let data = if is_put {
9329                    sink.lock().unwrap().push(body_text.clone());
9330                    serde_json::json!({
9331                        "uri": "at://did:plc:x/community.lexicon.rss.subscription/rk-keep",
9332                        "cid": "bafyreiafter"
9333                    })
9334                } else {
9335                    serde_json::json!({ "records": [existing.clone()] })
9336                };
9337                let body = serde_json::json!({ "ok": true, "data": data }).to_string();
9338                let resp = format!(
9339                    "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
9340                    body.len(),
9341                    body
9342                );
9343                let _ = sock.write_all(resp.as_bytes()).await;
9344                let _ = sock.flush().await;
9345            }
9346        });
9347        (format!("http://{addr}"), puts)
9348    }
9349
9350    /// The existing record a rename must not destroy.
9351    fn seeded_subscription() -> serde_json::Value {
9352        serde_json::json!({
9353            "uri": "at://did:plc:renamer4/community.lexicon.rss.subscription/rk-keep",
9354            "cid": "bafyreibefore",
9355            "value": {
9356                "$type": "community.lexicon.rss.subscription",
9357                "url": "https://example.com/feed.xml",
9358                "title": "Old title",
9359                "siteUrl": "https://example.com/blog",
9360                "fetchHint": "hourly",
9361                "private": false,
9362                "createdAt": "2024-03-01T00:00:00.000Z"
9363            }
9364        })
9365    }
9366
9367    /// An existing standard.site subscription, as the 19 in production are:
9368    /// written before this reader refused the scheme, still in the repo.
9369    fn seeded_at_uri_subscription() -> serde_json::Value {
9370        seeded_subscription_with_url(AT_URI_SUB)
9371    }
9372    /// An existing subscription record at `rk-keep` with the given URL.
9373    fn seeded_subscription_with_url(url: &str) -> serde_json::Value {
9374        serde_json::json!({
9375            "uri": "at://did:plc:renamer5/community.lexicon.rss.subscription/rk-keep",
9376            "cid": "bafyreibefore",
9377            "value": {
9378                "$type": "community.lexicon.rss.subscription",
9379                "url": url,
9380                "title": "Old title",
9381                "private": false,
9382                "createdAt": "2024-03-01T00:00:00.000Z"
9383            }
9384        })
9385    }
9386    const AT_URI_SUB: &str =
9387        "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lab2c4d5e6f7g8h";
9388    const AT_URI_SUB_ENC: &str =
9389        "at%3A%2F%2Fdid%3Aplc%3Aohutz6x5acjmpuulp3x7wxxc%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h";
9390
9391    /// **Retitling an existing `at://` subscription must work with the flag off.**
9392    ///
9393    /// The storability guard was placed before the repo lookup, so it refused
9394    /// any rename whose URL is an at-URI — including a pure title or folder
9395    /// change on a record that already exists. On main that rename succeeded;
9396    /// the 19 production records would have become un-editable. The flag gates
9397    /// what may be STORED in the cache, not whether a reader may edit their own
9398    /// record: the PDS write goes through, the cache row is simply not created.
9399    #[tokio::test]
9400    async fn retitling_an_existing_at_uri_subscription_survives_the_flag_being_off() {
9401        let did = "did:plc:renamer5";
9402        let (sidecar, puts) = spawn_rename_sidecar(seeded_at_uri_subscription()).await;
9403        let state = test_state_with_sidecar(&[did], &sidecar).await;
9404        assert!(
9405            !state.config.standard_site,
9406            "the flag must be off for this test"
9407        );
9408        let cookie = session_cookie(&state, did, None);
9409        let resp = router(state.clone())
9410            .oneshot(
9411                Request::builder()
9412                    .method("POST")
9413                    .uri("/subscriptions/rk-keep/rename")
9414                    .header(header::COOKIE, cookie)
9415                    .header("content-type", "application/x-www-form-urlencoded")
9416                    .body(Body::from(format!("url={AT_URI_SUB_ENC}&title=New+title")))
9417                    .unwrap(),
9418            )
9419            .await
9420            .unwrap();
9421        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9422        let loc = resp
9423            .headers()
9424            .get(header::LOCATION)
9425            .unwrap()
9426            .to_str()
9427            .unwrap();
9428        assert_eq!(loc, "/", "the retitle was refused: {loc}");
9429
9430        let bodies = puts.lock().unwrap().clone();
9431        assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
9432        let sent: serde_json::Value = serde_json::from_str(&bodies[0]).expect("put body is JSON");
9433        assert_eq!(
9434            sent["record"]["title"], "New title",
9435            "the rename did not apply"
9436        );
9437        assert_eq!(
9438            sent["record"]["url"], AT_URI_SUB,
9439            "the rename changed the URL"
9440        );
9441
9442        // The flag still means what it says for the CACHE: no at:// row.
9443        let cached: i64 = store::count_unpollable_feeds(&state.db).await.unwrap();
9444        assert_eq!(cached, 0, "a retitle stored an at:// row with the flag off");
9445    }
9446
9447    /// **Repointing a subscription AT an `at://` URI is still refused with the
9448    /// flag off** — the half of the guard that has to survive the fix above.
9449    /// Nothing reaches the PDS and nothing reaches the cache.
9450    #[tokio::test]
9451    async fn repointing_a_subscription_at_an_at_uri_is_refused_with_the_flag_off() {
9452        let did = "did:plc:renamer4";
9453        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
9454        let state = test_state_with_sidecar(&[did], &sidecar).await;
9455        let cookie = session_cookie(&state, did, None);
9456        let resp = router(state.clone())
9457            .oneshot(
9458                Request::builder()
9459                    .method("POST")
9460                    .uri("/subscriptions/rk-keep/rename")
9461                    .header(header::COOKIE, cookie)
9462                    .header("content-type", "application/x-www-form-urlencoded")
9463                    .body(Body::from(format!("url={AT_URI_SUB_ENC}&title=Moved")))
9464                    .unwrap(),
9465            )
9466            .await
9467            .unwrap();
9468        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9469        let loc = resp
9470            .headers()
9471            .get(header::LOCATION)
9472            .unwrap()
9473            .to_str()
9474            .unwrap();
9475        assert!(loc.contains("flash="), "the repoint was not refused: {loc}");
9476        assert!(
9477            !loc.contains("Private"),
9478            "a storability refusal was reported as a privacy one: {loc}"
9479        );
9480        assert!(
9481            puts.lock().unwrap().is_empty(),
9482            "the repoint reached the PDS"
9483        );
9484        let cached: i64 = store::count_unpollable_feeds(&state.db).await.unwrap();
9485        assert_eq!(cached, 0);
9486    }
9487
9488    /// Posts a retitle of `rk-keep` with its URL unchanged; returns the
9489    /// redirect location.
9490    async fn retitle_unchanged(state: &AppState, did: &str, url_enc: &str) -> String {
9491        let cookie = session_cookie(state, did, None);
9492        let resp = router(state.clone())
9493            .oneshot(
9494                Request::builder()
9495                    .method("POST")
9496                    .uri("/subscriptions/rk-keep/rename")
9497                    .header(header::COOKIE, cookie)
9498                    .header("content-type", "application/x-www-form-urlencoded")
9499                    .body(Body::from(format!("url={url_enc}&title=New+title")))
9500                    .unwrap(),
9501            )
9502            .await
9503            .unwrap();
9504        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9505        resp.headers()
9506            .get(header::LOCATION)
9507            .unwrap()
9508            .to_str()
9509            .unwrap()
9510            .to_string()
9511    }
9512
9513    /// **The privacy gate has the same ordering bug the storable gate had.**
9514    ///
9515    /// Another client can write a subscription whose URL is an at-URI that is
9516    /// not a well-formed publication URI at all — a feed generator, say. On
9517    /// main a retitle of it succeeded (the DID form fails `Url::parse`, which
9518    /// the classifier reads as `Public`). The narrowed at:// arm now fails
9519    /// closed as `Private` for it, and the gate ran before `url_changed` was
9520    /// known — so the record became un-editable, with a flash claiming it "was
9521    /// not saved or sent anywhere". Both gates now apply to a repoint only.
9522    #[tokio::test]
9523    async fn retitling_an_existing_at_uri_record_that_is_not_a_publication_survives() {
9524        let did = "did:plc:renamer5";
9525        let other = "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/app.bsky.feed.generator/whats-hot";
9526        let other_enc =
9527            "at%3A%2F%2Fdid%3Aplc%3Aohutz6x5acjmpuulp3x7wxxc%2Fapp.bsky.feed.generator%2Fwhats-hot";
9528        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription_with_url(other)).await;
9529        let state = test_state_with_sidecar(&[did], &sidecar).await;
9530        let loc = retitle_unchanged(&state, did, other_enc).await;
9531        assert_eq!(loc, "/", "the retitle was refused: {loc}");
9532        let bodies = puts.lock().unwrap().clone();
9533        assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
9534        let sent: serde_json::Value = serde_json::from_str(&bodies[0]).unwrap();
9535        assert_eq!(sent["record"]["title"], "New title");
9536        assert_eq!(sent["record"]["url"], other);
9537    }
9538
9539    /// **A repoint to a secret-bearing URL is still refused** — the half of
9540    /// the privacy gate that has to survive moving it behind `url_changed`.
9541    /// Found by mutation: with the gate deleted outright, nothing failed.
9542    #[tokio::test]
9543    async fn repointing_a_subscription_at_a_private_feed_is_refused() {
9544        let did = "did:plc:renamer4";
9545        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
9546        let state = test_state_with_sidecar(&[did], &sidecar).await;
9547        let cookie = session_cookie(&state, did, None);
9548        let resp = router(state.clone())
9549            .oneshot(
9550                Request::builder()
9551                    .method("POST")
9552                    .uri("/subscriptions/rk-keep/rename")
9553                    .header(header::COOKIE, cookie)
9554                    .header("content-type", "application/x-www-form-urlencoded")
9555                    .body(Body::from(
9556                        "url=https%3A%2F%2Fpaid.example%2Ffeed.xml%3Ftoken%3DZm9vYmFyc2VjcmV0dG9rZW4&title=Moved",
9557                    ))
9558                    .unwrap(),
9559            )
9560            .await
9561            .unwrap();
9562        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9563        let loc = resp
9564            .headers()
9565            .get(header::LOCATION)
9566            .unwrap()
9567            .to_str()
9568            .unwrap();
9569        assert!(
9570            loc.contains("Private"),
9571            "the private repoint was not refused: {loc}"
9572        );
9573        assert!(
9574            puts.lock().unwrap().is_empty(),
9575            "a secret-bearing URL reached the PDS"
9576        );
9577        // The repo's fixture token: opaque enough for the classifier, not a real
9578        // key shape (a Stripe-shaped fixture tripped the secret scanner — rightly).
9579        let leaked = "https://paid.example/feed.xml?token=Zm9vYmFyc2VjcmV0dG9rZW4";
9580        assert!(store::get_feed_by_url(&state.db, leaked)
9581            .await
9582            .unwrap()
9583            .is_none());
9584    }
9585
9586    /// **A retitle of a never-cached at:// subscription is not "at feed
9587    /// capacity".** The global-ceiling check keyed on "URL not in the cache",
9588    /// and an at:// record is never cached with the flag off — so at capacity,
9589    /// a pure retitle was refused for a row the handler would not insert. The
9590    /// check now runs once `url_changed` is known and only for a repoint.
9591    #[tokio::test]
9592    async fn retitling_an_uncached_at_uri_subscription_is_not_refused_at_feed_capacity() {
9593        let did = "did:plc:renamer5";
9594        let (sidecar, puts) = spawn_rename_sidecar(seeded_at_uri_subscription()).await;
9595        // Ceiling 1, and one real feed already fills it.
9596        let state = test_state_with_sidecar_and(&[did], &sidecar, false, 1).await;
9597        store::upsert_feed(
9598            &state.db,
9599            &store::NewFeed {
9600                url: "https://filler.example/feed.xml".to_string(),
9601                ..Default::default()
9602            },
9603        )
9604        .await
9605        .unwrap();
9606        let loc = retitle_unchanged(&state, did, AT_URI_SUB_ENC).await;
9607        assert_eq!(loc, "/", "the retitle was refused: {loc}");
9608        assert_eq!(
9609            puts.lock().unwrap().len(),
9610            1,
9611            "the retitle did not reach the PDS"
9612        );
9613        assert_eq!(
9614            store::count_feeds(&state.db).await.unwrap(),
9615            1,
9616            "a row was inserted"
9617        );
9618    }
9619
9620    /// POST `/subscriptions` with `url`, returning the redirect target.
9621    async fn subscribe(state: &AppState, did: &str, url_enc: &str) -> String {
9622        let cookie = session_cookie(state, did, None);
9623        let resp = router(state.clone())
9624            .oneshot(
9625                Request::builder()
9626                    .method("POST")
9627                    .uri("/subscriptions")
9628                    .header(header::COOKIE, cookie)
9629                    .header("content-type", "application/x-www-form-urlencoded")
9630                    .body(Body::from(format!("url={url_enc}")))
9631                    .unwrap(),
9632            )
9633            .await
9634            .unwrap();
9635        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9636        resp.headers()
9637            .get(header::LOCATION)
9638            .unwrap()
9639            .to_str()
9640            .unwrap()
9641            .to_string()
9642    }
9643
9644    /// A `com.atproto.identity.resolveHandle` that answers `did` for anything.
9645    async fn serve_resolver(did: &str) -> String {
9646        let base = crate::net::tests::serve_body(
9647            serde_json::json!({ "did": did }).to_string().into_bytes(),
9648        )
9649        .await;
9650        let port: u16 = base
9651            .trim_end_matches('/')
9652            .rsplit(':')
9653            .next()
9654            .unwrap()
9655            .parse()
9656            .unwrap();
9657        let host = format!("resolver-{port}.test");
9658        crate::net::test_host_override(&host, std::net::SocketAddr::from(([127, 0, 0, 1], port)));
9659        format!("http://{host}:{port}")
9660    }
9661
9662    fn with_config(mut state: AppState, f: impl FnOnce(&mut Config)) -> AppState {
9663        let mut config = (*state.config).clone();
9664        f(&mut config);
9665        state.config = std::sync::Arc::new(config);
9666        state
9667    }
9668
9669    /// **0.4.0 step 3: with the flag ON, a well-formed at:// paste is
9670    /// subscribed.** It was refused as unsupported while nothing could read a
9671    /// publication; the poller reads them now. Stored in DID form, as a
9672    /// `publication`, and written to the reader's PDS like any subscription.
9673    #[tokio::test]
9674    async fn a_well_formed_at_uri_paste_is_subscribed_with_the_flag_on() {
9675        let did = "did:plc:renamer5";
9676        let (sidecar, log) = spawn_logging_sidecar().await;
9677        let state = with_config(
9678            test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
9679            |c| {
9680                c.oauth.plc_directory = "http://plc.nowhere.invalid".into();
9681            },
9682        );
9683        let loc = subscribe(&state, did, AT_URI_SUB_ENC).await;
9684        assert_eq!(loc, "/", "the paste was refused: {loc}");
9685        let row = store::get_feed_by_url(&state.db, AT_URI_SUB)
9686            .await
9687            .unwrap()
9688            .expect("no feed row");
9689        assert_eq!(feed::FeedKind::of(&row.url), feed::FeedKind::Publication);
9690        let sent = log.lock().unwrap().join("\n");
9691        assert!(
9692            sent.contains(AT_URI_SUB),
9693            "the subscription was not written to the PDS: {sent}"
9694        );
9695    }
9696
9697    /// **A0 through the form** — the 0.4.0 exit test, end to end: a reader
9698    /// pastes a publication, it is stored and written to their PDS, and the
9699    /// first poll — the one subscribing runs at once — stores its documents.
9700    #[tokio::test]
9701    async fn a0_subscribing_from_the_form_delivers_entries() {
9702        let did = "did:plc:renamer5";
9703        let author = "did:plc:ohutz6x5acjmpuulp3x7wxxc";
9704        let site = AT_URI_SUB;
9705        let (plc, _) = crate::standard_site::tests::serve_repo(
9706            author,
9707            vec![
9708                (
9709                    lexicon::nsid::STANDARD_PUBLICATION,
9710                    "3lab2c4d5e6f7g8h",
9711                    serde_json::json!({ "name": "A0 Journal", "url": "https://a0.example" }),
9712                ),
9713                (
9714                    lexicon::nsid::STANDARD_DOCUMENT,
9715                    "3l2a0frmaaa2a",
9716                    serde_json::json!({ "title": "From the form", "path": "/f",
9717                        "publishedAt": "2026-07-11T00:00:00Z", "site": site }),
9718                ),
9719            ],
9720        )
9721        .await;
9722        let (sidecar, _log) = spawn_logging_sidecar().await;
9723        let state = with_config(
9724            test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
9725            |c| {
9726                c.oauth.plc_directory = plc;
9727            },
9728        );
9729        assert_eq!(subscribe(&state, did, AT_URI_SUB_ENC).await, "/");
9730        let row = store::get_feed_by_url(&state.db, site)
9731            .await
9732            .unwrap()
9733            .unwrap();
9734        let titles: Vec<String> = sqlx::query_scalar("SELECT title FROM entries WHERE feed_id = ?")
9735            .bind(row.id)
9736            .fetch_all(&state.db)
9737            .await
9738            .unwrap();
9739        assert_eq!(
9740            titles,
9741            vec!["From the form".to_string()],
9742            "the first poll stored nothing"
9743        );
9744        assert_eq!(row.title.as_deref(), Some("A0 Journal"));
9745    }
9746
9747    /// A handle-form paste is resolved to the DID before it is stored: a
9748    /// handle is a mutable name, and `feeds.url` is keyed on identity.
9749    #[tokio::test]
9750    async fn a_handle_form_paste_is_stored_by_its_did() {
9751        let did = "did:plc:renamer5";
9752        let author = "did:plc:ohutz6x5acjmpuulp3x7wxxc";
9753        let (sidecar, _log) = spawn_logging_sidecar().await;
9754        let resolver = serve_resolver(author).await;
9755        let state = with_config(
9756            test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
9757            |c| {
9758                c.resolver_base = resolver;
9759                c.oauth.plc_directory = "http://plc.nowhere.invalid".into();
9760            },
9761        );
9762        let loc = subscribe(
9763            &state,
9764            did,
9765            "at%3A%2F%2Falice.example.com%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
9766        )
9767        .await;
9768        assert_eq!(loc, "/", "the paste was refused: {loc}");
9769        assert!(
9770            store::get_feed_by_url(&state.db, AT_URI_SUB)
9771                .await
9772                .unwrap()
9773                .is_some(),
9774            "not stored by its DID"
9775        );
9776        assert_eq!(
9777            store::count_feeds(&state.db).await.unwrap(),
9778            1,
9779            "the handle form was stored too"
9780        );
9781    }
9782
9783    /// A resolver answering `did` that counts how often it was asked.
9784    async fn serve_counting_resolver(
9785        did: &str,
9786    ) -> (String, std::sync::Arc<std::sync::atomic::AtomicUsize>) {
9787        let (base, hits) = crate::net::tests::serve_body_counted(
9788            serde_json::json!({ "did": did }).to_string().into_bytes(),
9789        )
9790        .await;
9791        let port: u16 = base
9792            .trim_end_matches('/')
9793            .rsplit(':')
9794            .next()
9795            .unwrap()
9796            .parse()
9797            .unwrap();
9798        let host = format!("counting-resolver-{port}.test");
9799        crate::net::test_host_override(&host, std::net::SocketAddr::from(([127, 0, 0, 1], port)));
9800        (format!("http://{host}:{port}"), hits)
9801    }
9802
9803    /// Review of #230: the per-DID cap is documented as checked "BEFORE any
9804    /// fetch/resolve so an over-cap account can't even trigger an outbound
9805    /// request" — a handle paste resolved the handle first.
9806    #[tokio::test]
9807    async fn an_over_cap_handle_paste_makes_no_outbound_request() {
9808        let did = "did:plc:renamer5";
9809        let (sidecar, _log) = spawn_logging_sidecar().await;
9810        let (resolver, hits) = serve_counting_resolver("did:plc:ohutz6x5acjmpuulp3x7wxxc").await;
9811        let state = with_config(
9812            test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
9813            |c| {
9814                c.resolver_base = resolver;
9815                c.max_subs_per_did = 1;
9816            },
9817        );
9818        let feed_id = store::upsert_feed(
9819            &state.db,
9820            &store::NewFeed {
9821                url: "https://already.example/feed.xml".into(),
9822                ..Default::default()
9823            },
9824        )
9825        .await
9826        .unwrap();
9827        store::replace_sub_refs(&state.db, did, &[feed_id])
9828            .await
9829            .unwrap();
9830        let loc = subscribe(
9831            &state,
9832            did,
9833            "at%3A%2F%2Falice.example.com%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
9834        )
9835        .await;
9836        assert!(
9837            loc.contains("Subscription%20limit"),
9838            "expected the cap flash: {loc}"
9839        );
9840        assert_eq!(
9841            hits.load(std::sync::atomic::Ordering::SeqCst),
9842            0,
9843            "an over-cap paste resolved a handle"
9844        );
9845    }
9846
9847    /// Review of #230: an authority that is neither a valid DID nor a valid
9848    /// handle — `did:plc:TOOSHORT`, an uppercase DID — went to the resolver as
9849    /// a "handle". It is unsupported, and asks nobody anything.
9850    #[tokio::test]
9851    async fn a_malformed_did_paste_is_unsupported_with_the_flag_on() {
9852        let did = "did:plc:renamer5";
9853        let (sidecar, _log) = spawn_logging_sidecar().await;
9854        let (resolver, hits) = serve_counting_resolver("did:plc:ohutz6x5acjmpuulp3x7wxxc").await;
9855        let state = with_config(
9856            test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
9857            |c| {
9858                c.resolver_base = resolver;
9859            },
9860        );
9861        for authority in [
9862            "did%3Aplc%3ATOOSHORT",
9863            "did%3Aplc%3AOHUTZ6X5ACJMPUULP3X7WXXC",
9864            "bad%0Ahandle.example",
9865        ] {
9866            let loc = subscribe(
9867                &state,
9868                did,
9869                &format!("at%3A%2F%2F{authority}%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h"),
9870            )
9871            .await;
9872            assert!(
9873                loc.contains("kind%20of%20feed"),
9874                "{authority}: expected the unsupported flash: {loc}"
9875            );
9876        }
9877        assert_eq!(
9878            hits.load(std::sync::atomic::Ordering::SeqCst),
9879            0,
9880            "a malformed authority reached the resolver"
9881        );
9882        assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
9883    }
9884
9885    /// A handle that does not resolve is refused, and nothing is stored.
9886    #[tokio::test]
9887    async fn an_unresolvable_handle_paste_is_refused() {
9888        let did = "did:plc:renamer5";
9889        let (sidecar, _log) = spawn_logging_sidecar().await;
9890        let state = with_config(
9891            test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
9892            |c| {
9893                c.resolver_base = "http://resolver.nowhere.invalid".into();
9894            },
9895        );
9896        let loc = subscribe(
9897            &state,
9898            did,
9899            "at%3A%2F%2Fnobody.example.com%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
9900        )
9901        .await;
9902        assert!(
9903            loc.contains("resolve%20the%20handle"),
9904            "expected the unresolvable-handle flash: {loc}"
9905        );
9906        assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
9907    }
9908
9909    /// An at:// URI that is not a publication is refused, flag on or off.
9910    #[tokio::test]
9911    async fn a_non_publication_at_uri_paste_is_refused() {
9912        let did = "did:plc:renamer5";
9913        let (sidecar, _log) = spawn_logging_sidecar().await;
9914        let state = test_state_with_sidecar_and(&[did], &sidecar, true, 0).await;
9915        let loc = subscribe(
9916            &state,
9917            did,
9918            "at%3A%2F%2Fdid%3Aplc%3Aohutz6x5acjmpuulp3x7wxxc%2Fapp.bsky.feed.post%2F3lab2c4d5e6f7g8h",
9919        )
9920        .await;
9921        assert!(
9922            loc.contains("kind%20of%20feed"),
9923            "expected the unsupported flash: {loc}"
9924        );
9925        assert_eq!(store::count_feeds(&state.db).await.unwrap(), 0);
9926    }
9927
9928    /// A mixed-case scheme is canonicalised at input, not refused and not
9929    /// stored as a second spelling of the same publication.
9930    #[tokio::test]
9931    async fn a_mixed_case_at_scheme_paste_is_stored_canonically() {
9932        let did = "did:plc:renamer5";
9933        let (sidecar, _log) = spawn_logging_sidecar().await;
9934        let state = with_config(
9935            test_state_with_sidecar_and(&[did], &sidecar, true, 0).await,
9936            |c| {
9937                c.oauth.plc_directory = "http://plc.nowhere.invalid".into();
9938            },
9939        );
9940        let loc = subscribe(&state, did, &AT_URI_SUB_ENC.replacen("at", "At", 1)).await;
9941        assert_eq!(loc, "/", "the paste was refused: {loc}");
9942        assert!(store::get_feed_by_url(&state.db, AT_URI_SUB)
9943            .await
9944            .unwrap()
9945            .is_some());
9946    }
9947
9948    /// **With the flag ON, an OPML at:// entry is stored.** The one storage
9949    /// path that is meant to work today, asserted with the flag actually on.
9950    #[tokio::test]
9951    async fn opml_import_stores_an_at_uri_entry_with_the_flag_on() {
9952        let did = "did:plc:renamer5";
9953        let (sidecar, _puts) = spawn_rename_sidecar(seeded_subscription()).await;
9954        let state = test_state_with_sidecar_and(&[did], &sidecar, true, 0).await;
9955        let opml = format!(
9956            "<?xml version=\"1.0\"?>\n<opml version=\"2.0\"><head><title>t</title></head><body>\n\
9957             <outline type=\"rss\" text=\"Real\" xmlUrl=\"https://real.example/feed.xml\"/>\n\
9958             <outline type=\"rss\" text=\"Pub\" xmlUrl=\"{AT_URI_SUB}\"/>\n\
9959             </body></opml>"
9960        );
9961        let (ct, body) = opml_multipart(opml.as_bytes());
9962        let cookie = session_cookie(&state, did, None);
9963        let resp = router(state.clone())
9964            .oneshot(
9965                Request::builder()
9966                    .method("POST")
9967                    .uri("/opml")
9968                    .header(header::COOKIE, cookie)
9969                    .header("content-type", ct)
9970                    .body(Body::from(body))
9971                    .unwrap(),
9972            )
9973            .await
9974            .unwrap();
9975        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
9976        let loc = resp
9977            .headers()
9978            .get(header::LOCATION)
9979            .unwrap()
9980            .to_str()
9981            .unwrap();
9982        assert!(
9983            loc.contains("Imported%202%20feeds"),
9984            "unexpected flash: {loc}"
9985        );
9986        assert!(
9987            !loc.contains("skipped"),
9988            "the at:// entry was skipped with the flag on: {loc}"
9989        );
9990        let stored = store::get_feed_by_url(&state.db, AT_URI_SUB).await.unwrap();
9991        assert!(
9992            stored.is_some(),
9993            "the at:// entry was not stored with the flag on"
9994        );
9995    }
9996
9997    /// **A retitle must not cache a secret-bearing URL.** Moving the privacy
9998    /// gate behind `url_changed` was right for the PDS write — the record is
9999    /// the reader's — but the cache write was gated only on `storable`, which
10000    /// any http(s) URL is. So a retitle of a record another client wrote with
10001    /// a tokened feed URL inserted that URL into the shared `feeds` table,
10002    /// where the poller would fail it every cycle and print it on the admin
10003    /// page. main refused the whole rename; this keeps the record editable and
10004    /// the cache clean, as `resolve_subscriptions` already does for the same
10005    /// record.
10006    #[tokio::test]
10007    async fn retitling_a_secret_bearing_record_does_not_cache_its_url() {
10008        let did = "did:plc:renamer5";
10009        let tokened = "https://www.patreon.com/rss/author?auth=Zm9vYmFyc2VjcmV0dG9rZW4";
10010        let tokened_enc =
10011            "https%3A%2F%2Fwww.patreon.com%2Frss%2Fauthor%3Fauth%3DZm9vYmFyc2VjcmV0dG9rZW4";
10012        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription_with_url(tokened)).await;
10013        let state = test_state_with_sidecar(&[did], &sidecar).await;
10014        let loc = retitle_unchanged(&state, did, tokened_enc).await;
10015        assert_eq!(loc, "/", "the retitle was refused: {loc}");
10016        assert_eq!(
10017            puts.lock().unwrap().len(),
10018            1,
10019            "the retitle did not reach the PDS"
10020        );
10021        assert!(
10022            store::get_feed_by_url(&state.db, tokened)
10023                .await
10024                .unwrap()
10025                .is_none(),
10026            "a secret-bearing URL was written to the shared cache by a retitle"
10027        );
10028    }
10029
10030    /// **On a repoint, storability is decided before privacy and capacity** —
10031    /// the same ordering the add path got. A malformed at:// target drew the
10032    /// private/paid flash, and at capacity a well-formed one drew "try again
10033    /// later" for a URL that can never be accepted with the flag off.
10034    #[tokio::test]
10035    async fn repointing_at_a_malformed_at_uri_is_refused_as_unsupported() {
10036        let did = "did:plc:renamer4";
10037        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
10038        let state = test_state_with_sidecar(&[did], &sidecar).await;
10039        let cookie = session_cookie(&state, did, None);
10040        let resp = router(state.clone())
10041            .oneshot(
10042                Request::builder()
10043                    .method("POST")
10044                    .uri("/subscriptions/rk-keep/rename")
10045                    .header(header::COOKIE, cookie)
10046                    .header("content-type", "application/x-www-form-urlencoded")
10047                    .body(Body::from(
10048                        "url=at%3A%2F%2Fdid%3Aplc%3ATOOSHORT%2Fsite.standard.publication%2F3lab&title=Moved",
10049                    ))
10050                    .unwrap(),
10051            )
10052            .await
10053            .unwrap();
10054        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10055        let loc = resp
10056            .headers()
10057            .get(header::LOCATION)
10058            .unwrap()
10059            .to_str()
10060            .unwrap();
10061        assert!(
10062            loc.contains("kind%20of%20feed"),
10063            "expected the unsupported flash: {loc}"
10064        );
10065        assert!(
10066            !loc.contains("Private"),
10067            "a typo was reported as a paid feed: {loc}"
10068        );
10069        assert!(puts.lock().unwrap().is_empty());
10070    }
10071
10072    #[tokio::test]
10073    async fn repointing_at_an_at_uri_at_capacity_is_refused_as_unsupported_not_capacity() {
10074        let did = "did:plc:renamer4";
10075        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
10076        let state = test_state_with_sidecar_and(&[did], &sidecar, false, 1).await;
10077        store::upsert_feed(
10078            &state.db,
10079            &store::NewFeed {
10080                url: "https://filler.example/feed.xml".to_string(),
10081                ..Default::default()
10082            },
10083        )
10084        .await
10085        .unwrap();
10086        let cookie = session_cookie(&state, did, None);
10087        let resp = router(state.clone())
10088            .oneshot(
10089                Request::builder()
10090                    .method("POST")
10091                    .uri("/subscriptions/rk-keep/rename")
10092                    .header(header::COOKIE, cookie)
10093                    .header("content-type", "application/x-www-form-urlencoded")
10094                    .body(Body::from(format!("url={AT_URI_SUB_ENC}&title=Moved")))
10095                    .unwrap(),
10096            )
10097            .await
10098            .unwrap();
10099        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10100        let loc = resp
10101            .headers()
10102            .get(header::LOCATION)
10103            .unwrap()
10104            .to_str()
10105            .unwrap();
10106        assert!(
10107            loc.contains("kind%20of%20feed"),
10108            "expected the unsupported flash: {loc}"
10109        );
10110        assert!(
10111            !loc.contains("capacity"),
10112            "an unacceptable URL was reported as a capacity problem: {loc}"
10113        );
10114        assert!(puts.lock().unwrap().is_empty());
10115    }
10116
10117    /// **`url_changed` compares like for like.** The form value is trimmed;
10118    /// the record's URL was compared raw, so a record another client wrote
10119    /// with a trailing space read as a repoint on every retitle and re-armed
10120    /// every gate — including the one that made an at:// record un-editable.
10121    #[tokio::test]
10122    async fn retitling_a_record_whose_url_carries_whitespace_is_not_a_repoint() {
10123        let did = "did:plc:renamer5";
10124        let padded = format!("{AT_URI_SUB} ");
10125        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription_with_url(&padded)).await;
10126        let state = test_state_with_sidecar(&[did], &sidecar).await;
10127        // The manage row posts the record's URL verbatim, padding included.
10128        let loc = retitle_unchanged(&state, did, &format!("{AT_URI_SUB_ENC}%20")).await;
10129        assert_eq!(
10130            loc, "/",
10131            "the retitle was treated as a repoint and refused: {loc}"
10132        );
10133        let bodies = puts.lock().unwrap().clone();
10134        assert_eq!(bodies.len(), 1);
10135        let sent: serde_json::Value = serde_json::from_str(&bodies[0]).unwrap();
10136        assert_eq!(
10137            sent["record"]["url"], AT_URI_SUB,
10138            "the padding was not normalised away"
10139        );
10140    }
10141
10142    /// **A retitle inserts no cache row.** The ceiling is checked on a repoint
10143    /// only, so the trailing upsert must not create a row for an unchanged URL
10144    /// that has none — with the flag on and the cache full, each retitle of a
10145    /// never-cached at:// record was a row past the cap. An existing row still
10146    /// gets its title kept in step.
10147    #[tokio::test]
10148    async fn retitling_an_uncached_record_at_capacity_inserts_no_row() {
10149        let did = "did:plc:renamer5";
10150        let (sidecar, puts) = spawn_rename_sidecar(seeded_at_uri_subscription()).await;
10151        let state = test_state_with_sidecar_and(&[did], &sidecar, true, 1).await;
10152        store::upsert_feed(
10153            &state.db,
10154            &store::NewFeed {
10155                url: "https://filler.example/feed.xml".to_string(),
10156                ..Default::default()
10157            },
10158        )
10159        .await
10160        .unwrap();
10161        let loc = retitle_unchanged(&state, did, AT_URI_SUB_ENC).await;
10162        assert_eq!(loc, "/", "the retitle was refused: {loc}");
10163        assert_eq!(puts.lock().unwrap().len(), 1);
10164        assert_eq!(
10165            store::count_feeds(&state.db).await.unwrap(),
10166            1,
10167            "a retitle inserted a cache row past the ceiling"
10168        );
10169    }
10170
10171    /// **The add path's at:// pre-check is about the MESSAGE, so it is
10172    /// case-insensitive.** `Url::parse` folds the scheme, so `AT://…` skipped
10173    /// the pre-check and the classifier's at:// arm alike, parsed as `at`, and
10174    /// tripped the secret heuristic on the rkey — the private/paid flash the
10175    /// pre-check exists to avoid. Storage stays case-sensitive; this does not
10176    /// touch it.
10177    #[tokio::test]
10178    async fn an_uppercase_at_scheme_paste_is_refused_as_unsupported() {
10179        let did = "did:plc:typoist";
10180        let state = test_state_with_caps(did, 0, 0).await;
10181        let cookie = session_cookie(&state, did, None);
10182        let resp = router(state.clone())
10183            .oneshot(
10184                Request::builder()
10185                    .method("POST")
10186                    .uri("/subscriptions")
10187                    .header(header::COOKIE, cookie)
10188                    .header("content-type", "application/x-www-form-urlencoded")
10189                    .body(Body::from(
10190                        "url=AT%3A%2F%2Falice.example.com%2Fsite.standard.publication%2F3lab2c4d5e6f7g8h",
10191                    ))
10192                    .unwrap(),
10193            )
10194            .await
10195            .unwrap();
10196        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10197        let loc = resp
10198            .headers()
10199            .get(header::LOCATION)
10200            .unwrap()
10201            .to_str()
10202            .unwrap();
10203        assert!(
10204            loc.contains("kind%20of%20feed"),
10205            "expected the unsupported flash: {loc}"
10206        );
10207        assert!(!loc.contains("Private"), "reported as a paid feed: {loc}");
10208    }
10209
10210    /// **A rename must not destroy the fields the form never carries.**
10211    ///
10212    /// `update_subscription` is a `putRecord` — the WHOLE record is replaced, per
10213    /// its own doc. The handler built a fresh `Subscription::new(url, now())`, so
10214    /// every field absent from `templates/manage_row.html` (which posts only
10215    /// `url`, `title`, `folder`) was written back as its default:
10216    ///
10217    /// | field | before | after |
10218    /// |---|---|---|
10219    /// | `siteUrl` | whatever the feed advertised | gone |
10220    /// | `fetchHint` | as set | gone |
10221    /// | `private` | as set | gone |
10222    /// | `createdAt` | original subscribe time | reset to now |
10223    ///
10224    /// `createdAt` is the worst of the four: it is the sort key for "when did I
10225    /// subscribe", it is unrecoverable once overwritten, and nothing in the UI
10226    /// tells the reader it moved.
10227    ///
10228    /// Asserted on the BYTES THE SIDECAR RECEIVES, not on a `Subscription` built
10229    /// in the test — the record only becomes wrong on the way out, so checking
10230    /// the value we passed in would pass just as happily with the fix removed.
10231    #[tokio::test]
10232    async fn renaming_preserves_the_fields_the_form_never_carries() {
10233        let did = "did:plc:renamer4";
10234        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
10235        let state = test_state_with_sidecar(&[did], &sidecar).await;
10236        let cookie = session_cookie(&state, did, None);
10237
10238        let resp = router(state.clone())
10239            .oneshot(
10240                Request::builder()
10241                    .method("POST")
10242                    .uri("/subscriptions/rk-keep/rename")
10243                    .header(header::COOKIE, cookie)
10244                    .header("content-type", "application/x-www-form-urlencoded")
10245                    // Exactly what the manage row posts: url, title, folder.
10246                    .body(Body::from(
10247                        "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=New+title&folder=Tech",
10248                    ))
10249                    .unwrap(),
10250            )
10251            .await
10252            .unwrap();
10253        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10254
10255        let bodies = puts.lock().unwrap().clone();
10256        assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
10257        let body = &bodies[0];
10258        // Anchors the negative assertions: an empty capture would satisfy them.
10259        assert!(
10260            body.contains("community.lexicon.rss.subscription"),
10261            "captured no usable put body: {body:?}"
10262        );
10263
10264        let sent: serde_json::Value = serde_json::from_str(body).expect("put body is JSON");
10265        let record = &sent["record"];
10266
10267        // What the form DID carry must be applied.
10268        assert_eq!(record["title"], "New title", "the rename did not apply");
10269        assert_eq!(record["folder"], "Tech", "the re-folder did not apply");
10270
10271        // What the form did NOT carry must survive.
10272        assert_eq!(
10273            record["createdAt"], "2024-03-01T00:00:00.000Z",
10274            "the rename reset createdAt — the reader's subscribe time is gone \
10275             from their own repo, and nothing told them"
10276        );
10277        assert_eq!(
10278            record["siteUrl"], "https://example.com/blog",
10279            "the rename erased siteUrl"
10280        );
10281        assert_eq!(record["fetchHint"], "hourly", "the rename erased fetchHint");
10282        assert_eq!(record["private"], false, "the rename erased private");
10283    }
10284
10285    /// **Repointing at a different feed drops that feed's properties, but not
10286    /// the subscription's.**
10287    ///
10288    /// `siteUrl` and `fetchHint` describe the feed the subscription points at,
10289    /// so carrying them onto a different URL would leave a site link for the old
10290    /// feed hanging off the new one. `createdAt` and `private` are properties of
10291    /// the SUBSCRIPTION and survive a repoint — the reader subscribed when they
10292    /// subscribed, whatever the URL was later corrected to.
10293    #[tokio::test]
10294    async fn repointing_a_feed_drops_the_old_feeds_properties_but_keeps_the_subscriptions() {
10295        let did = "did:plc:renamer4";
10296        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
10297        let state = test_state_with_sidecar(&[did], &sidecar).await;
10298        let cookie = session_cookie(&state, did, None);
10299
10300        let resp = router(state.clone())
10301            .oneshot(
10302                Request::builder()
10303                    .method("POST")
10304                    .uri("/subscriptions/rk-keep/rename")
10305                    .header(header::COOKIE, cookie)
10306                    .header("content-type", "application/x-www-form-urlencoded")
10307                    // A DIFFERENT feed URL from the seeded record.
10308                    .body(Body::from(
10309                        "url=https%3A%2F%2Fother.example%2Ffeed.xml&title=Repointed",
10310                    ))
10311                    .unwrap(),
10312            )
10313            .await
10314            .unwrap();
10315        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10316
10317        let bodies = puts.lock().unwrap().clone();
10318        assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
10319        assert!(
10320            bodies[0].contains("community.lexicon.rss.subscription"),
10321            "captured no usable put body: {:?}",
10322            bodies[0]
10323        );
10324        let sent: serde_json::Value = serde_json::from_str(&bodies[0]).expect("put body is JSON");
10325        let record = &sent["record"];
10326
10327        assert_eq!(record["url"], "https://other.example/feed.xml");
10328        // The old feed's properties are gone rather than misattributed.
10329        assert!(
10330            record.get("siteUrl").is_none() || record["siteUrl"].is_null(),
10331            "the old feed's site link followed the subscription to a new feed: {record}"
10332        );
10333        assert!(
10334            record.get("fetchHint").is_none() || record["fetchHint"].is_null(),
10335            "the old feed's fetch hint followed the subscription to a new feed: {record}"
10336        );
10337        // The subscription's own properties survive.
10338        assert_eq!(
10339            record["createdAt"], "2024-03-01T00:00:00.000Z",
10340            "a repoint is still not a new subscription; createdAt must not move"
10341        );
10342        assert_eq!(record["private"], false, "the repoint erased private");
10343    }
10344
10345    /// **A rename against an rkey that is not in the repo writes NOTHING.**
10346    ///
10347    /// `update_subscription` is a `putRecord`, which CREATES the record when the
10348    /// rkey does not exist — with whatever `createdAt` we hand it. So without
10349    /// this refusal a rename against a stale or wrong rkey manufactures a
10350    /// subscription dated today, which is the bug this whole change exists to
10351    /// fix, arriving by a different door.
10352    ///
10353    /// The guard was untested when first written: removing it left all 733 tests
10354    /// green. An untested guard against the exact defect being fixed is how the
10355    /// two previous rounds of this problem got through.
10356    #[tokio::test]
10357    async fn renaming_an_unknown_rkey_writes_nothing() {
10358        let did = "did:plc:renamer4";
10359        // The sidecar serves exactly one record, at rkey `rk-keep`.
10360        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
10361        let state = test_state_with_sidecar(&[did], &sidecar).await;
10362        let cookie = session_cookie(&state, did, None);
10363
10364        let resp = router(state.clone())
10365            .oneshot(
10366                Request::builder()
10367                    .method("POST")
10368                    // ...and this is not it.
10369                    .uri("/subscriptions/rk-does-not-exist/rename")
10370                    .header(header::COOKIE, cookie)
10371                    .header("content-type", "application/x-www-form-urlencoded")
10372                    .body(Body::from(
10373                        "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=Ghost",
10374                    ))
10375                    .unwrap(),
10376            )
10377            .await
10378            .unwrap();
10379
10380        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10381        let loc = resp
10382            .headers()
10383            .get(header::LOCATION)
10384            .unwrap()
10385            .to_str()
10386            .unwrap();
10387        assert!(
10388            loc.contains("flash="),
10389            "an unknown rkey redirected as though the rename had worked: {loc}"
10390        );
10391        assert!(
10392            puts.lock().unwrap().is_empty(),
10393            "a rename against an unknown rkey wrote a record — putRecord would \
10394             CREATE it, dated today: {:?}",
10395            puts.lock().unwrap()
10396        );
10397    }
10398
10399    /// **A `site_url` the client actually sends is applied, not dropped.**
10400    ///
10401    /// `templates/manage_row.html` does not post this field, so it is tempting
10402    /// to read the arm that handles it as dead code. It is not:
10403    /// `RenameSubForm` carries `site_url`, so a hand-crafted POST reaches it
10404    /// today. Discarding the value instead of applying it left all 733 tests
10405    /// green.
10406    ///
10407    /// The value is scheme-checked on the way out by the repo-boundary vet, so
10408    /// this is a coverage gap rather than an exposure — but an untested path
10409    /// that writes a URL into the reader's PDS should not stay untested.
10410    #[tokio::test]
10411    async fn a_client_supplied_site_url_reaches_the_record() {
10412        let did = "did:plc:renamer4";
10413        let (sidecar, puts) = spawn_rename_sidecar(seeded_subscription()).await;
10414        let state = test_state_with_sidecar(&[did], &sidecar).await;
10415        let cookie = session_cookie(&state, did, None);
10416
10417        let resp = router(state.clone())
10418            .oneshot(
10419                Request::builder()
10420                    .method("POST")
10421                    .uri("/subscriptions/rk-keep/rename")
10422                    .header(header::COOKIE, cookie)
10423                    .header("content-type", "application/x-www-form-urlencoded")
10424                    // Same feed URL, but carrying a site_url the manage row
10425                    // never sends.
10426                    .body(Body::from(
10427                        "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=Kept\
10428                         &site_url=https%3A%2F%2Ftyped.example%2Fsite",
10429                    ))
10430                    .unwrap(),
10431            )
10432            .await
10433            .unwrap();
10434        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10435
10436        let bodies = puts.lock().unwrap().clone();
10437        assert_eq!(bodies.len(), 1, "expected exactly one put, got {bodies:?}");
10438        assert!(
10439            bodies[0].contains("community.lexicon.rss.subscription"),
10440            "captured no usable put body: {:?}",
10441            bodies[0]
10442        );
10443        let sent: serde_json::Value = serde_json::from_str(&bodies[0]).expect("put body is JSON");
10444        assert_eq!(
10445            sent["record"]["siteUrl"], "https://typed.example/site",
10446            "the client's siteUrl was dropped; the seeded record's survived instead"
10447        );
10448    }
10449
10450    /// **A rename whose read fails writes NOTHING.**
10451    ///
10452    /// This is the property most easily lost when someone later touches this
10453    /// handler: falling back to `Subscription::new` on a read error looks like
10454    /// graceful degradation and is in fact the original bug, reinstated on
10455    /// exactly the path where it is hardest to notice. The reader must be told
10456    /// instead.
10457    #[tokio::test]
10458    async fn a_rename_whose_read_fails_writes_nothing() {
10459        let did = "did:plc:renamer5";
10460        // A port that accepts nothing: the read cannot succeed.
10461        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
10462        let dead = format!("http://{}", listener.local_addr().unwrap());
10463        drop(listener);
10464
10465        let state = test_state_with_sidecar(&[did], &dead).await;
10466        let cookie = session_cookie(&state, did, None);
10467        let before = store::count_feeds(&state.db).await.unwrap();
10468
10469        let resp = router(state.clone())
10470            .oneshot(
10471                Request::builder()
10472                    .method("POST")
10473                    .uri("/subscriptions/rk-keep/rename")
10474                    .header(header::COOKIE, cookie)
10475                    .header("content-type", "application/x-www-form-urlencoded")
10476                    .body(Body::from(
10477                        "url=https%3A%2F%2Fexample.com%2Ffeed.xml&title=Doomed",
10478                    ))
10479                    .unwrap(),
10480            )
10481            .await
10482            .unwrap();
10483
10484        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
10485        let loc = resp
10486            .headers()
10487            .get(header::LOCATION)
10488            .unwrap()
10489            .to_str()
10490            .unwrap();
10491        assert!(
10492            loc.contains("flash="),
10493            "a failed read redirected as though the rename had worked: {loc}"
10494        );
10495        assert_eq!(
10496            store::count_feeds(&state.db).await.unwrap(),
10497            before,
10498            "a rename that could not read the record still wrote to the cache"
10499        );
10500    }
10501
10502    /// Folder pre-selection regression: the manage rename row must mark the
10503    /// feed's CURRENT folder `<option>` as `selected`, so an untouched Save
10504    /// re-submits the current folder instead of silently un-foldering the feed.
10505    /// A loose (un-foldered) feed must mark "No folder" selected instead. Renders
10506    /// `ManageTemplate` directly so no PDS/sidecar round-trip is needed.
10507    #[test]
10508    fn manage_rename_row_preselects_current_folder() {
10509        let nav = Nav {
10510            handle: "@reader.example".to_string(),
10511            avatar: "RE".to_string(),
10512            view: "unread".to_string(),
10513            scope_qs: String::new(),
10514            folders: Vec::new(),
10515            loose_feeds: Vec::new(),
10516            manage_active: true,
10517        };
10518        let folder_options = vec![
10519            FolderOption {
10520                uri: "at://did:plc:x/app.folder/work".to_string(),
10521                name: "Work".to_string(),
10522            },
10523            FolderOption {
10524                uri: "at://did:plc:x/app.folder/fun".to_string(),
10525                name: "Fun".to_string(),
10526            },
10527        ];
10528        // A foldered feed (in "Work") and a loose feed (no folder), each with a
10529        // non-empty rkey so the rename form renders.
10530        let foldered = FeedView {
10531            rkey: "sub-foldered".to_string(),
10532            url: "https://work.example/feed.xml".to_string(),
10533            title: "Work Feed".to_string(),
10534            unread: 0,
10535            selected: false,
10536            folder: Some("at://did:plc:x/app.folder/work".to_string()),
10537        };
10538        let loose = FeedView {
10539            rkey: "sub-loose".to_string(),
10540            url: "https://loose.example/feed.xml".to_string(),
10541            title: "Loose Feed".to_string(),
10542            unread: 0,
10543            selected: false,
10544            folder: None,
10545        };
10546        let tmpl = ManageTemplate {
10547            version: VERSION,
10548            repo_url: REPO_URL,
10549            kofi_url: KOFI_URL,
10550            flash: String::new(),
10551            alert: String::new(),
10552            nav,
10553            folder_options,
10554            folders: vec![FolderView {
10555                rkey: "folder-work".to_string(),
10556                uri: "at://did:plc:x/app.folder/work".to_string(),
10557                name: "Work".to_string(),
10558                feeds: vec![foldered],
10559                selected: false,
10560            }],
10561            loose_feeds: vec![loose],
10562        };
10563        let html = tmpl.render().unwrap();
10564
10565        // The foldered feed's "Work" option is pre-selected.
10566        assert!(
10567            html.contains(
10568                r#"<option value="at://did:plc:x/app.folder/work" selected>Work</option>"#
10569            ),
10570            "foldered feed must pre-select its current folder: {html}"
10571        );
10572        // The loose feed's "No folder" option is pre-selected (appears for the
10573        // loose row, which has folder=None).
10574        assert!(
10575            html.contains(r#"<option value="" selected>No folder</option>"#),
10576            "loose feed must pre-select 'No folder': {html}"
10577        );
10578    }
10579
10580    /// **The public stats page carries no user data.**
10581    ///
10582    /// It is reachable by anyone, so the thing worth pinning is what it does
10583    /// NOT say: nothing about how many people use the instance, nothing about
10584    /// which feeds fail, nothing about who reads what.
10585    #[tokio::test]
10586    async fn the_public_stats_page_exposes_no_user_data() {
10587        let state = test_state(&[]).await;
10588        store::ensure_seed(&state.db, &["did:plc:someone".to_string()])
10589            .await
10590            .unwrap();
10591
10592        let resp = router(state)
10593            .oneshot(
10594                Request::builder()
10595                    .uri("/stats")
10596                    .body(Body::empty())
10597                    .unwrap(),
10598            )
10599            .await
10600            .unwrap();
10601        assert_eq!(resp.status(), StatusCode::OK, "stats must be public");
10602
10603        let body = String::from_utf8(
10604            axum::body::to_bytes(resp.into_body(), usize::MAX)
10605                .await
10606                .unwrap()
10607                .to_vec(),
10608        )
10609        .unwrap();
10610
10611        // Structural checks, not word checks. The page's own prose says it
10612        // publishes no error rates, so searching for that PHRASE finds the
10613        // disclaimer rather than a leak — the first version of this test failed
10614        // on exactly that. What matters is whether identifiers or the
10615        // admin-only figures are present.
10616        assert!(
10617            !body.contains("did:"),
10618            "the public stats page leaked an identifier"
10619        );
10620        for admin_only in ["errp50ms", "p95ms", "live backend", "ok_count"] {
10621            assert!(
10622                !body.contains(admin_only),
10623                "the public page is showing the admin metrics column {admin_only:?}"
10624            );
10625        }
10626        // And it does render the aggregate it exists for.
10627        assert!(body.contains("Feeds tracked"));
10628        assert!(body.contains("Waiting to be polled"));
10629    }
10630
10631    /// **The two states that stop feeds updating must be visible.**
10632    ///
10633    /// `overdue` and `polled_last_hour` move in BOTH and distinguish neither —
10634    /// and `overdue` moves the WRONG WAY for backoff, because backoff is applied
10635    /// by pushing `next_poll` forward, so a feed failing every fetch drops out of
10636    /// the backlog and makes the page read healthier. That inversion is what this
10637    /// test pins: a broken feed must raise a number, not lower one.
10638    #[tokio::test]
10639    async fn stats_distinguishes_backoff_from_a_watermark_pause() {
10640        let state = test_state(&[]).await;
10641        // Three feeds: one healthy, one flaky, one long dead.
10642        for (url, errors) in [
10643            ("https://ok.example/f.xml", 0),
10644            ("https://flaky.example/f.xml", 2),
10645            ("https://dead.example/f.xml", 9),
10646        ] {
10647            store::upsert_feed(
10648                &state.db,
10649                &store::NewFeed {
10650                    url: url.to_string(),
10651                    // Pushed forward, exactly as backoff does — so none of these
10652                    // are counted as `overdue`.
10653                    next_poll: Some("2099-01-01T00:00:00Z".to_string()),
10654                    ..Default::default()
10655                },
10656            )
10657            .await
10658            .unwrap();
10659            for _ in 0..errors {
10660                store::bump_feed_errors(
10661                    &state.db,
10662                    url,
10663                    feed::FailureKind::Fetch,
10664                    "connection refused",
10665                )
10666                .await
10667                .unwrap();
10668            }
10669        }
10670
10671        let render_stats = |state: AppState| async move {
10672            let resp = router(state)
10673                .oneshot(
10674                    Request::builder()
10675                        .uri("/stats")
10676                        .body(Body::empty())
10677                        .unwrap(),
10678                )
10679                .await
10680                .unwrap();
10681            assert_eq!(resp.status(), StatusCode::OK);
10682            String::from_utf8(
10683                axum::body::to_bytes(resp.into_body(), usize::MAX)
10684                    .await
10685                    .unwrap()
10686                    .to_vec(),
10687            )
10688            .unwrap()
10689        };
10690
10691        // **The fixture must actually be RUNNING, or this test measures nothing.**
10692        // `test_state` leaves `schedulers_enabled` false, and `fetching_state`
10693        // checks that BEFORE the watermark — so without these two lines every
10694        // render below reports "off" and the watermark can never surface. The
10695        // assertions still passed, for reasons unrelated to what they name: see
10696        // the two comments below.
10697        state.runtime_health.set_schedulers_enabled(true);
10698        state
10699            .runtime_health
10700            .poll_tick_completed(crate::store::now_unix());
10701
10702        let body = render_stats(state.clone()).await;
10703        assert!(
10704            body.contains("Failing"),
10705            "backoff is still invisible on the public page"
10706        );
10707        // 2 failing, 1 of them badly (>= BADLY_BROKEN_ERRORS). Matched on the
10708        // value rather than on surrounding whitespace, so re-indenting the
10709        // template cannot break this.
10710        assert!(
10711            body.contains("2, 1 badly"),
10712            "expected '2, 1 badly' in the failing row; got:\n{}",
10713            body.split("Failing")
10714                .nth(1)
10715                .unwrap_or("")
10716                .chars()
10717                .take(300)
10718                .collect::<String>()
10719        );
10720        // Not paused, and the backlog is genuinely empty — which is exactly the
10721        // reading that used to be indistinguishable from healthy.
10722        //
10723        // **Asserted by EXCLUDING the other states, not by matching "running".**
10724        // The `off` row reads "the poller is not running on this instance", which
10725        // contains "running" — so the bare substring passed while the page was
10726        // reporting the exact opposite of what this line claims to check.
10727        assert!(
10728            !body.contains("the poller is not running")
10729                && !body.contains("the cache is at its size limit")
10730                && !body.contains("has not completed a round"),
10731            "expected the running state; the page reported a stopped one",
10732        );
10733
10734        // Now trip the watermark. Nothing in the database changes; only the
10735        // recorded runtime state does — which is the whole reason it needed a
10736        // home outside the log stream.
10737        state.runtime_health.set_watermark(true);
10738        let paused = render_stats(state.clone()).await;
10739        // Matched on the paused row's OWN sentence. The bare word "paused" also
10740        // appeared in the page's explanatory prose, so this assertion passed
10741        // whether or not the row rendered — and trimming that prose is what
10742        // exposed it. This phrase exists only inside the `paused` branch.
10743        assert!(
10744            paused.contains("the cache is at its size limit"),
10745            "a watermark pause is still invisible on the public page"
10746        );
10747
10748        // Still no identifiers: these are counts, not feeds.
10749        for leak in ["ok.example", "flaky.example", "dead.example", "did:"] {
10750            assert!(
10751                !paused.contains(leak),
10752                "the public page leaked {leak:?} while reporting failures"
10753            );
10754        }
10755    }
10756
10757    /// **`/admin/metrics` is gated, and nothing checked that it was.**
10758    ///
10759    /// Deleting the `admin_seed_dids` check left the entire suite green. That
10760    /// was survivable while the page held only aggregate timings; it is not now,
10761    /// because this branch puts **per-feed URLs and remote error text** behind
10762    /// that gate. A guarantee nothing checks is a comment, and this one is now
10763    /// the only thing standing between a signed-in stranger and the operational
10764    /// picture the handler's own doc says is not public.
10765    ///
10766    /// All three doors: no session, a session that is not an admin, and the
10767    /// admin itself.
10768    #[tokio::test]
10769    async fn admin_metrics_is_refused_to_everyone_but_an_admin() {
10770        let admin = "did:plc:adminseed";
10771        // **Only the admin is in ALLOWED_DIDS**, because `admin_seed_dids()`
10772        // IS that list — deliberately, per its doc: "the same people I trust on
10773        // this instance". Production sets it to the bootstrap DID alone.
10774        //
10775        // A genuine non-admin is therefore someone holding a beta seat granted
10776        // by an invite, not by the allow-list. Seeding both would have made
10777        // both admins and quietly turned the 403 assertion below into a test of
10778        // nothing — which is exactly what the first draft of this did.
10779        let state = test_state(&[admin]).await;
10780        store::grant_access(&state.db, "did:plc:ordinaryuser", None, "invite", None)
10781            .await
10782            .unwrap();
10783        let url = "https://broken.example/f.xml";
10784        store::upsert_feed(
10785            &state.db,
10786            &store::NewFeed {
10787                url: url.to_string(),
10788                ..Default::default()
10789            },
10790        )
10791        .await
10792        .unwrap();
10793        store::bump_feed_errors(
10794            &state.db,
10795            url,
10796            feed::FailureKind::Fetch,
10797            "SENTINEL_ADMIN_ONLY",
10798        )
10799        .await
10800        .unwrap();
10801
10802        let get = |state: AppState, cookie: Option<String>| async move {
10803            let mut req = Request::builder().uri("/admin/metrics");
10804            if let Some(c) = cookie {
10805                req = req.header(header::COOKIE, c);
10806            }
10807            let resp = router(state)
10808                .oneshot(req.body(Body::empty()).unwrap())
10809                .await
10810                .unwrap();
10811            let status = resp.status();
10812            let body = String::from_utf8(
10813                axum::body::to_bytes(resp.into_body(), usize::MAX)
10814                    .await
10815                    .unwrap()
10816                    .to_vec(),
10817            )
10818            .unwrap();
10819            (status, body)
10820        };
10821
10822        // No session at all.
10823        let (status, body) = get(state.clone(), None).await;
10824        assert_eq!(status, StatusCode::UNAUTHORIZED);
10825        assert!(
10826            !body.contains("SENTINEL_ADMIN_ONLY"),
10827            "leaked to anonymous: {body}"
10828        );
10829
10830        // A real, signed-in user who is not an admin.
10831        let ordinary = session_cookie(&state, "did:plc:ordinaryuser", None);
10832        let (status, body) = get(state.clone(), Some(ordinary)).await;
10833        assert_eq!(
10834            status,
10835            StatusCode::FORBIDDEN,
10836            "a non-admin session was let in"
10837        );
10838        assert!(
10839            !body.contains("SENTINEL_ADMIN_ONLY") && !body.contains("broken.example"),
10840            "leaked to a non-admin: {body}",
10841        );
10842
10843        // The admin does get it — otherwise the two refusals above are
10844        // satisfied by the endpoint being broken for everyone.
10845        let admin_cookie = session_cookie(&state, admin, None);
10846        let (status, body) = get(state, Some(admin_cookie)).await;
10847        assert_eq!(status, StatusCode::OK);
10848        assert!(
10849            body.contains("SENTINEL_ADMIN_ONLY"),
10850            "admin cannot see it: {body}"
10851        );
10852    }
10853
10854    /// **The cause a public count cannot carry belongs on the admin page.**
10855    ///
10856    /// The public histogram is four coarse buckets, and `fetch` is the coarsest:
10857    /// #159's own error — `guarded_get` bailing on a 304 — lands there beside
10858    /// DNS failure, timeout and SSRF refusal. So the histogram alone would NOT
10859    /// have separated "sixty dead publishers" from "one bug here", which is the
10860    /// case it was justified by.
10861    ///
10862    /// The answer is not a finer public vocabulary — `/stats` promises never
10863    /// which feed and never whose, and a bucket per error string would break
10864    /// that. It is to put the detail where per-feed data is already allowed.
10865    /// `/admin/metrics` is gated on `ALLOWED_DIDS` and already carries an
10866    /// operational picture.
10867    ///
10868    /// Asserts both halves: the detail IS on the admin page, and is NOT on the
10869    /// public one.
10870    #[tokio::test]
10871    async fn the_admin_page_names_failing_feeds_and_the_public_page_does_not() {
10872        let admin = "did:plc:adminseed";
10873        let state = test_state(&[admin]).await;
10874        let url = "https://broken.example/f.xml";
10875        store::upsert_feed(
10876            &state.db,
10877            &store::NewFeed {
10878                url: url.to_string(),
10879                ..Default::default()
10880            },
10881        )
10882        .await
10883        .unwrap();
10884        store::bump_feed_errors(
10885            &state.db,
10886            url,
10887            feed::FailureKind::Fetch,
10888            "SENTINEL_REDIRECT_NO_LOCATION",
10889        )
10890        .await
10891        .unwrap();
10892
10893        let cookie = session_cookie(&state, admin, None);
10894        let resp = router(state.clone())
10895            .oneshot(
10896                Request::builder()
10897                    .uri("/admin/metrics")
10898                    .header(header::COOKIE, cookie)
10899                    .body(Body::empty())
10900                    .unwrap(),
10901            )
10902            .await
10903            .unwrap();
10904        assert_eq!(resp.status(), StatusCode::OK);
10905        let admin_body = String::from_utf8(
10906            axum::body::to_bytes(resp.into_body(), usize::MAX)
10907                .await
10908                .unwrap()
10909                .to_vec(),
10910        )
10911        .unwrap();
10912        assert!(
10913            admin_body.contains("SENTINEL_REDIRECT_NO_LOCATION"),
10914            "the admin page does not carry the failure detail: {admin_body}",
10915        );
10916        assert!(
10917            admin_body.contains("broken.example"),
10918            "the admin page does not name the failing feed: {admin_body}",
10919        );
10920
10921        // The public page still carries neither.
10922        let resp = router(state)
10923            .oneshot(
10924                Request::builder()
10925                    .uri("/stats")
10926                    .body(Body::empty())
10927                    .unwrap(),
10928            )
10929            .await
10930            .unwrap();
10931        let public = String::from_utf8(
10932            axum::body::to_bytes(resp.into_body(), usize::MAX)
10933                .await
10934                .unwrap()
10935                .to_vec(),
10936        )
10937        .unwrap();
10938        for secret in ["SENTINEL_REDIRECT_NO_LOCATION", "broken.example"] {
10939            assert!(
10940                !public.contains(secret),
10941                "{secret:?} reached the PUBLIC stats page: {public}",
10942            );
10943        }
10944    }
10945
10946    /// **A direct poll must settle the error columns, like the scheduler does.**
10947    ///
10948    /// `add_subscription` polls through `feed::poll_feed` rather than the
10949    /// scheduler, and `poll_feed` writes validators and `last_polled` but never
10950    /// touches `consecutive_errors` — that is the scheduler's job, and this path
10951    /// is not the scheduler.
10952    ///
10953    /// So a feed that was failing, is re-subscribed, and polls SUCCESSFULLY kept
10954    /// its old count and its old cause: the public page went on reporting it
10955    /// under `Failing`, under `badly_broken`, and under a cause, for as long as
10956    /// the stale backoff horizon lasted — up to 24h — while the reader was
10957    /// demonstrably fetching it.
10958    #[tokio::test]
10959    async fn a_successful_direct_poll_clears_a_stale_failure() {
10960        let state = test_state(&[]).await;
10961        let url = "https://recovered.example/f.xml";
10962        store::upsert_feed(
10963            &state.db,
10964            &store::NewFeed {
10965                url: url.to_string(),
10966                ..Default::default()
10967            },
10968        )
10969        .await
10970        .unwrap();
10971        store::bump_feed_errors(&state.db, url, feed::FailureKind::Fetch, "SENTINEL_OLD")
10972            .await
10973            .unwrap();
10974        // Park it on a stale backoff horizon, as a real failing feed would be.
10975        sqlx::query("UPDATE feeds SET next_poll = '2099-01-01T00:00:00Z' WHERE url = ?1")
10976            .bind(url)
10977            .execute(&state.db)
10978            .await
10979            .unwrap();
10980
10981        // The publisher is fixed: a successful poll happens on this path.
10982        feed::settle_poll(
10983            &state.db,
10984            url,
10985            &feed::PollOutcome::NotModified,
10986            state.config.poll_interval,
10987        )
10988        .await;
10989
10990        let row: (i64, Option<String>, Option<String>) = sqlx::query_as(
10991            "SELECT consecutive_errors, last_error_kind, next_poll FROM feeds WHERE url = ?1",
10992        )
10993        .bind(url)
10994        .fetch_one(&state.db)
10995        .await
10996        .unwrap();
10997        assert_eq!(row.0, 0, "a successful direct poll left the error streak");
10998        assert_eq!(row.1, None, "a successful direct poll left a stale cause");
10999        // **The half the first fix missed.** Clearing the count fixed the
11000        // REPORTING; the feed stayed parked until 2099. A working feed must be
11001        // rescheduled on its normal cadence, not left on the failure horizon.
11002        let next = row.2.expect("next_poll was cleared to NULL");
11003        // Not merely "moved off 2099" — rescheduled on the CADENCE, not a
11004        // backoff. A mutation that reschedules successes with backoff_for(1)
11005        // (5 min) also moves it off 2099, so the interval is asserted.
11006        let parsed = chrono::DateTime::parse_from_rfc3339(&next).unwrap();
11007        let delta = parsed
11008            .signed_duration_since(chrono::Utc::now())
11009            .num_seconds();
11010        let cadence = state.config.poll_interval.as_secs() as i64;
11011        assert!(
11012            (cadence - 60..=cadence + 60).contains(&delta),
11013            "expected rescheduling on the {cadence}s cadence, got {delta}s (next_poll={next})"
11014        );
11015    }
11016
11017    /// The mirror case: a first poll that FAILS must be visible at all.
11018    ///
11019    /// `Ok(outcome) => info!(...)` discarded a `PollOutcome::Failed`, so a
11020    /// subscription whose very first fetch failed sat at `consecutive_errors = 0`
11021    /// with a NULL cause — invisible to the page built to count exactly that.
11022    #[tokio::test]
11023    async fn a_failing_direct_poll_is_recorded() {
11024        let state = test_state(&[]).await;
11025        let url = "https://born-broken.example/f.xml";
11026        store::upsert_feed(
11027            &state.db,
11028            &store::NewFeed {
11029                url: url.to_string(),
11030                ..Default::default()
11031            },
11032        )
11033        .await
11034        .unwrap();
11035
11036        feed::settle_poll(
11037            &state.db,
11038            url,
11039            &feed::PollOutcome::Failed {
11040                backoff: std::time::Duration::from_secs(300),
11041                kind: feed::FailureKind::Parse,
11042                detail: "SENTINEL_BORN_BROKEN".to_string(),
11043            },
11044            state.config.poll_interval,
11045        )
11046        .await;
11047
11048        let row: (i64, Option<String>, Option<String>) = sqlx::query_as(
11049            "SELECT consecutive_errors, last_error_kind, next_poll FROM feeds WHERE url = ?1",
11050        )
11051        .bind(url)
11052        .fetch_one(&state.db)
11053        .await
11054        .unwrap();
11055        assert_eq!(row.0, 1, "a failed first poll was not counted");
11056        assert_eq!(
11057            row.1.as_deref(),
11058            Some("parse"),
11059            "its cause was not recorded"
11060        );
11061        // And it is BACKED OFF on the schedule the scheduler would use — not
11062        // left with a NULL next_poll that `due_feeds` sorts first and re-polls
11063        // on the very next tick.
11064        let next = row.2.expect("a failed direct poll left next_poll NULL");
11065        let parsed = chrono::DateTime::parse_from_rfc3339(&next).unwrap();
11066        let delta = parsed
11067            .signed_duration_since(chrono::Utc::now())
11068            .num_seconds();
11069        assert!(
11070            (240..=360).contains(&delta),
11071            "expected ~300s backoff after one failure, got {delta}s (next_poll={next})"
11072        );
11073    }
11074
11075    /// **The breakdown must sum to the Failing figure above it.**
11076    ///
11077    /// The histogram counts `last_error_kind IS NOT NULL`; `Failing` counts
11078    /// `consecutive_errors > 0`. On a migrated database every row that was
11079    /// already failing has a NULL kind — correctly, it was never recorded — so
11080    /// the two do not reconcile and the page shows "70 failing" beside "3
11081    /// fetch" with 67 silently unaccounted for. On deploy day the row vanishes
11082    /// entirely while the prose still promises a breakdown.
11083    ///
11084    /// An explicit `unknown` bucket is the honest shape: the page says how many
11085    /// it cannot explain rather than omitting them.
11086    #[tokio::test]
11087    async fn the_failure_breakdown_accounts_for_every_failing_feed() {
11088        let state = test_state(&[]).await;
11089        // Two legacy rows: failing, with no recorded cause.
11090        for url in [
11091            "https://legacy1.example/f.xml",
11092            "https://legacy2.example/f.xml",
11093        ] {
11094            store::upsert_feed(
11095                &state.db,
11096                &store::NewFeed {
11097                    url: url.to_string(),
11098                    next_poll: Some("2099-01-01T00:00:00Z".to_string()),
11099                    ..Default::default()
11100                },
11101            )
11102            .await
11103            .unwrap();
11104            sqlx::query("UPDATE feeds SET consecutive_errors = 4 WHERE url = ?1")
11105                .bind(url)
11106                .execute(&state.db)
11107                .await
11108                .unwrap();
11109        }
11110        // One row with a recorded cause.
11111        store::upsert_feed(
11112            &state.db,
11113            &store::NewFeed {
11114                url: "https://known.example/f.xml".to_string(),
11115                next_poll: Some("2099-01-01T00:00:00Z".to_string()),
11116                ..Default::default()
11117            },
11118        )
11119        .await
11120        .unwrap();
11121        store::bump_feed_errors(
11122            &state.db,
11123            "https://known.example/f.xml",
11124            feed::FailureKind::Status,
11125            "SENTINEL",
11126        )
11127        .await
11128        .unwrap();
11129
11130        let now = chrono::Utc::now();
11131        let health = store::poll_health(
11132            &state.db,
11133            &now.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
11134            &(now - chrono::Duration::hours(1)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
11135        )
11136        .await
11137        .unwrap();
11138        let counted: i64 = health.failure_kinds.iter().map(|(_, n)| n).sum();
11139        assert_eq!(
11140            counted, health.in_backoff,
11141            "the breakdown ({counted}) does not account for all {} failing feeds: {:?}",
11142            health.in_backoff, health.failure_kinds,
11143        );
11144        assert!(
11145            health
11146                .failure_kinds
11147                .iter()
11148                .any(|(k, n)| k == "unknown" && *n == 2),
11149            "no unknown bucket for the legacy rows: {:?}",
11150            health.failure_kinds,
11151        );
11152    }
11153
11154    /// **The breakdown is ordered by count, and the assertion can see it.**
11155    ///
11156    /// The first version of this asserted with three `contains` calls, which
11157    /// cannot observe order — deleting `ORDER BY` from the query passed.
11158    #[tokio::test]
11159    async fn the_failure_breakdown_is_ordered_by_count() {
11160        let state = test_state(&[]).await;
11161        for (url, kind, n) in [
11162            ("https://p1.example/f.xml", feed::FailureKind::Parse, 1),
11163            ("https://f1.example/f.xml", feed::FailureKind::Fetch, 1),
11164            ("https://f2.example/f.xml", feed::FailureKind::Fetch, 1),
11165            ("https://f3.example/f.xml", feed::FailureKind::Fetch, 1),
11166            ("https://s1.example/f.xml", feed::FailureKind::Status, 1),
11167            ("https://s2.example/f.xml", feed::FailureKind::Status, 1),
11168        ] {
11169            store::upsert_feed(
11170                &state.db,
11171                &store::NewFeed {
11172                    url: url.to_string(),
11173                    next_poll: Some("2099-01-01T00:00:00Z".to_string()),
11174                    ..Default::default()
11175                },
11176            )
11177            .await
11178            .unwrap();
11179            for _ in 0..n {
11180                store::bump_feed_errors(&state.db, url, kind, "d")
11181                    .await
11182                    .unwrap();
11183            }
11184        }
11185        let now = chrono::Utc::now();
11186        let health = store::poll_health(
11187            &state.db,
11188            &now.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
11189            &(now - chrono::Duration::hours(1)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
11190        )
11191        .await
11192        .unwrap();
11193        let labels: Vec<&str> = health
11194            .failure_kinds
11195            .iter()
11196            .map(|(k, _)| k.as_str())
11197            .collect();
11198        assert_eq!(
11199            labels,
11200            ["fetch", "status", "parse"],
11201            "not ordered by count, descending: {:?}",
11202            health.failure_kinds,
11203        );
11204    }
11205
11206    /// **Failing feeds are grouped by CAUSE, and still never named.**
11207    ///
11208    /// `badly_broken` could say that sixty feeds were failing and not whether
11209    /// that was sixty dead publishers or one bug here. It was the latter — #159,
11210    /// a `304 Not Modified` read as a malformed redirect — and the page could
11211    /// not say so, which is most of why it went unexamined.
11212    ///
11213    /// The second half of this test is the constraint that shapes the first:
11214    /// `/stats` is public and promises machines-not-people, *never which feed
11215    /// and never whose*. A histogram of causes keeps that promise; a list of
11216    /// failing URLs would break it, and is the obvious way to build this.
11217    #[tokio::test]
11218    async fn stats_groups_failures_by_cause_without_naming_any_feed() {
11219        let state = test_state(&[]).await;
11220        for (url, kind, detail, errors) in [
11221            // Detail strings are distinctive SENTINELS, not plausible English.
11222            // A first pass used "not a feed", which the page's own explanation
11223            // of the `parse` kind contains verbatim — the privacy assertion
11224            // fired on static copy rather than on a leak. A sentinel cannot
11225            // collide with prose.
11226            (
11227                "https://a.example/f.xml",
11228                feed::FailureKind::Fetch,
11229                "SENTINEL_CONNREFUSED",
11230                3,
11231            ),
11232            (
11233                "https://b.example/f.xml",
11234                feed::FailureKind::Fetch,
11235                "SENTINEL_DNSFAIL",
11236                2,
11237            ),
11238            (
11239                "https://c.example/f.xml",
11240                feed::FailureKind::Status,
11241                "SENTINEL_404",
11242                1,
11243            ),
11244            (
11245                "https://d.example/f.xml",
11246                feed::FailureKind::Parse,
11247                "SENTINEL_UNPARSEABLE",
11248                1,
11249            ),
11250        ] {
11251            store::upsert_feed(
11252                &state.db,
11253                &store::NewFeed {
11254                    url: url.to_string(),
11255                    next_poll: Some("2099-01-01T00:00:00Z".to_string()),
11256                    ..Default::default()
11257                },
11258            )
11259            .await
11260            .unwrap();
11261            for _ in 0..errors {
11262                store::bump_feed_errors(&state.db, url, kind, detail)
11263                    .await
11264                    .unwrap();
11265            }
11266        }
11267
11268        let resp = router(state.clone())
11269            .oneshot(
11270                Request::builder()
11271                    .uri("/stats")
11272                    .body(Body::empty())
11273                    .unwrap(),
11274            )
11275            .await
11276            .unwrap();
11277        assert_eq!(resp.status(), StatusCode::OK);
11278        let body = String::from_utf8(
11279            axum::body::to_bytes(resp.into_body(), usize::MAX)
11280                .await
11281                .unwrap()
11282                .to_vec(),
11283        )
11284        .unwrap();
11285
11286        // Descending by count: two fetch, then one each, tie-broken by name.
11287        assert!(
11288            body.contains("2 fetch") && body.contains("1 status") && body.contains("1 parse"),
11289            "the cause histogram did not render: {body}",
11290        );
11291
11292        // **The privacy half.** No feed URL, host, or error detail reaches the
11293        // public page — only counts by kind.
11294        for secret in [
11295            "a.example",
11296            "b.example",
11297            "c.example",
11298            "d.example",
11299            "SENTINEL_CONNREFUSED",
11300            "SENTINEL_DNSFAIL",
11301            "SENTINEL_404",
11302            "SENTINEL_UNPARSEABLE",
11303        ] {
11304            assert!(
11305                !body.contains(secret),
11306                "{secret:?} reached the PUBLIC stats page: {body}",
11307            );
11308        }
11309    }
11310
11311    /// `/health` must prove the process can reach its database, and must report
11312    /// the loop state without letting it change the status code.
11313    #[tokio::test]
11314    async fn health_checks_the_database_and_reports_the_loops() {
11315        let state = test_state(&[]).await;
11316        let body_of = |state: AppState| async move {
11317            let resp = router(state)
11318                .oneshot(
11319                    Request::builder()
11320                        .uri("/health")
11321                        .body(Body::empty())
11322                        .unwrap(),
11323                )
11324                .await
11325                .unwrap();
11326            let status = resp.status();
11327            let body = String::from_utf8(
11328                axum::body::to_bytes(resp.into_body(), usize::MAX)
11329                    .await
11330                    .unwrap()
11331                    .to_vec(),
11332            )
11333            .unwrap();
11334            (status, body)
11335        };
11336
11337        // The boot stamp is what `main` sets; the router alone does not, so this
11338        // starts "unknown" and the uptime branch below drives it explicitly.
11339        state
11340            .runtime_health
11341            .set_started_at(chrono::Utc::now().timestamp());
11342
11343        let (status, body) = body_of(state.clone()).await;
11344        assert_eq!(status, StatusCode::OK);
11345        assert!(
11346            body.contains("db: ok"),
11347            "health did not probe the DB: {body}"
11348        );
11349        assert!(
11350            body.contains("uptime:"),
11351            "no uptime — the first thing anyone asks about a container that may \
11352             be restarting: {body}"
11353        );
11354        assert!(body.contains("poller:"), "no scheduler heartbeat: {body}");
11355        assert!(body.contains("polling-paused: no"), "{body}");
11356        assert!(body.contains("backend:"), "{body}");
11357        assert!(body.contains("oauth-runtime:"), "{body}");
11358
11359        // A watermark pause is REPORTED but must not fail the check. A failed
11360        // check DEREGISTERS this machine from the proxy — and it is the only
11361        // machine — so it would turn "feeds are behind" into "the site is down"
11362        // for as long as the disk stays full.
11363        state.runtime_health.set_watermark(true);
11364        state.runtime_health.set_schedulers_enabled(true);
11365        let (status, body) = body_of(state.clone()).await;
11366        assert_eq!(
11367            status,
11368            StatusCode::OK,
11369            "a watermark pause must not fail the liveness check: {body}"
11370        );
11371        assert!(body.contains("polling-paused: yes"), "{body}");
11372        // Schedulers on but no tick yet — and that must not read as "0s ago",
11373        // which is the healthiest possible answer to an unanswered question.
11374        assert!(
11375            body.contains("poller: not-yet-ticked"),
11376            "a never-ticked poller must say so: {body}"
11377        );
11378
11379        // A stale heartbeat is likewise reported, not fatal.
11380        let stale_after = health_tick_stale_secs(configured_poll_tick());
11381        let long_ago = chrono::Utc::now().timestamp() - (stale_after + 60);
11382        state.runtime_health.poll_tick_completed(long_ago);
11383        let (status, body) = body_of(state.clone()).await;
11384        assert_eq!(
11385            status,
11386            StatusCode::OK,
11387            "a stale poller must not 503: {body}"
11388        );
11389        assert!(body.contains("poller: stale"), "{body}");
11390
11391        // **A poller that has never ticked stops being benign.**
11392        //
11393        // In a crash loop with 30 s+ boot cycles the poller never reaches its
11394        // first tick, so `not-yet-ticked` was reported forever and the heartbeat
11395        // could not detect the one failure mode the startup delays were added
11396        // for. It is read against uptime now.
11397        state.runtime_health.poll_tick_completed(0); // reset to "never"
11398        state
11399            .runtime_health
11400            .set_started_at(chrono::Utc::now().timestamp() - (HEALTH_FIRST_TICK_GRACE_SECS + 60));
11401        let (status, body) = body_of(state.clone()).await;
11402        assert_eq!(status, StatusCode::OK);
11403        assert!(
11404            body.contains("poller: stale never-ticked"),
11405            "a poller that never ticked long after boot still reads as benign: {body}"
11406        );
11407
11408        // A closed pool is a real outage: nothing can be served, and a restart is
11409        // the correct response. THIS is what the status code is for.
11410        state.db.close().await;
11411        let (status, body) = body_of(state.clone()).await;
11412        assert_eq!(
11413            status,
11414            StatusCode::SERVICE_UNAVAILABLE,
11415            "an unreachable database must fail the check: {body}"
11416        );
11417        assert!(body.starts_with("FAIL"), "{body}");
11418        // Coarse, not the raw sqlx error: an unauthenticated caller learning
11419        // exactly which failure it hit is an attack-progress oracle, and this
11420        // endpoint is exempt from the origin lock.
11421        assert!(
11422            !body.contains("PoolClosed") && !body.contains("sqlx"),
11423            "health leaked the raw database error to an unauthenticated caller: {body}"
11424        );
11425    }
11426
11427    /// The staleness threshold must track the configured tick.
11428    ///
11429    /// Hardcoded at 15 minutes, an operator who raised
11430    /// `FEATHERREADER_POLL_TICK_SECS` above 900 got a permanent `poller: stale`
11431    /// in the body the deployment docs tell them to alert on.
11432    #[test]
11433    fn the_stale_threshold_follows_the_poll_tick() {
11434        // A fast tick keeps the floor — five 60 s ticks is 5 minutes, and
11435        // alerting that early would fire on any brief hiccup.
11436        assert_eq!(
11437            health_tick_stale_secs(Duration::from_secs(60)),
11438            HEALTH_TICK_STALE_FLOOR_SECS
11439        );
11440        // A slow tick raises it, so a legitimately-configured loop is never
11441        // permanently "stale".
11442        let slow = Duration::from_secs(30 * 60);
11443        assert!(
11444            health_tick_stale_secs(slow) > slow.as_secs() as i64,
11445            "a 30-minute tick must not be stale after one interval"
11446        );
11447        assert_eq!(health_tick_stale_secs(slow), 30 * 60 * 5);
11448        // And it cannot overflow into nonsense on an absurd value.
11449        assert!(health_tick_stale_secs(Duration::from_secs(u64::MAX)) > 0);
11450    }
11451
11452    /// `/stats` must distinguish "nothing is polling" from "polling is fine".
11453    ///
11454    /// `polling_paused` alone rendered "running" for three different states,
11455    /// including the two where nothing polls at all — on the page added to
11456    /// answer exactly that question.
11457    #[tokio::test]
11458    async fn stats_does_not_call_a_stopped_poller_running() {
11459        let state = test_state(&[]).await;
11460        let render = |state: AppState| async move {
11461            let resp = router(state)
11462                .oneshot(
11463                    Request::builder()
11464                        .uri("/stats")
11465                        .body(Body::empty())
11466                        .unwrap(),
11467                )
11468                .await
11469                .unwrap();
11470            assert_eq!(resp.status(), StatusCode::OK);
11471            String::from_utf8(
11472                axum::body::to_bytes(resp.into_body(), usize::MAX)
11473                    .await
11474                    .unwrap()
11475                    .to_vec(),
11476            )
11477            .unwrap()
11478        };
11479
11480        // Schedulers never started: not "running".
11481        let body = render(state.clone()).await;
11482        assert!(
11483            body.contains("the poller is not running on this instance"),
11484            "a disabled poller renders as healthy"
11485        );
11486
11487        // Started, but no tick has finished yet.
11488        state.runtime_health.set_schedulers_enabled(true);
11489        let body = render(state.clone()).await;
11490        assert!(
11491            body.contains("no poll has finished since this instance booted"),
11492            "a poller that has not ticked renders as healthy"
11493        );
11494
11495        // Ticking: running.
11496        state
11497            .runtime_health
11498            .poll_tick_completed(chrono::Utc::now().timestamp());
11499        let body = render(state.clone()).await;
11500        assert!(
11501            body.contains("running"),
11502            "a healthy poller must read as running"
11503        );
11504
11505        // Paused at the watermark still wins over "running".
11506        state.runtime_health.set_watermark(true);
11507        let body = render(state.clone()).await;
11508        assert!(
11509            body.contains("the cache is at its size limit"),
11510            "a watermark pause is hidden once the poller is ticking"
11511        );
11512    }
11513
11514    /// **An UNMEASURED database must not fail the check.**
11515    ///
11516    /// `/health` is the one path exempt from the Cloudflare origin lock and
11517    /// absent from the rate limiter, and `DbProbeGuard` releases its claim on
11518    /// drop WITHOUT recording a verdict — so a cancelled request (a client
11519    /// disconnect is enough) leaves the verdict at "none", and a concurrent
11520    /// caller reads it. Treating that as a failure turned an unauthenticated
11521    /// request into a lever on the only signal the platform acts on. The
11522    /// previous version of this code had the opposite bug and reported `ok` for
11523    /// a database nothing had read; "unknown" is neither.
11524    #[tokio::test]
11525    async fn health_reports_an_unmeasured_database_without_failing() {
11526        use crate::runtime_health::DbProbe;
11527        let state = test_state(&[]).await;
11528
11529        // Hold the probe claim, exactly as an in-flight request would, and never
11530        // record a verdict — the cancelled-request state.
11531        let held = state
11532            .runtime_health
11533            .begin_db_probe()
11534            .unwrap_or_else(|_| panic!("a fresh RuntimeHealth must grant the first claim"));
11535
11536        let resp = router(state.clone())
11537            .oneshot(
11538                Request::builder()
11539                    .uri("/health")
11540                    .body(Body::empty())
11541                    .unwrap(),
11542            )
11543            .await
11544            .unwrap();
11545        let status = resp.status();
11546        let body = String::from_utf8(
11547            axum::body::to_bytes(resp.into_body(), usize::MAX)
11548                .await
11549                .unwrap()
11550                .to_vec(),
11551        )
11552        .unwrap();
11553        drop(held);
11554
11555        assert_eq!(
11556            status,
11557            StatusCode::OK,
11558            "an unmeasured database failed the check, which an unauthenticated \
11559             caller can cause on demand: {body}"
11560        );
11561        assert!(
11562            body.contains("db: unknown"),
11563            "the unmeasured state must still be REPORTED: {body}"
11564        );
11565        assert!(!body.starts_with("FAIL"), "{body}");
11566        // **And it must not read as `ok` either.** `fly.toml` tells operators to
11567        // alert on the BODY for everything the status code ignores, so a first
11568        // line identical to the healthy one makes a monitor keying on `^ok` read
11569        // green in exactly the state this enum exists to surface.
11570        assert!(
11571            !body.starts_with("ok"),
11572            "the unmeasured state is indistinguishable from healthy to a \
11573             body-matching monitor: {body}"
11574        );
11575        assert!(body.starts_with("unknown"), "{body}");
11576
11577        // **A BORROWED failure must 503 too.**
11578        //
11579        // This previously recorded `Failed` and then closed the pool — but
11580        // `record` consumes the guard and releases the claim, so the request won
11581        // it, ran a live probe against the closed pool, and failed on its own.
11582        // The 503 passed for the wrong reason and the borrow path — the whole
11583        // point of the three-state enum on the read side — had no coverage.
11584        //
11585        // Holding the claim forces the borrow, so the recorded verdict is what
11586        // gets reported.
11587        let held = state
11588            .runtime_health
11589            .begin_db_probe()
11590            .unwrap_or_else(|_| panic!("claim"));
11591        state
11592            .runtime_health
11593            .record_for_test(DbProbe::Failed("unavailable".to_string()));
11594        let resp = router(state.clone())
11595            .oneshot(
11596                Request::builder()
11597                    .uri("/health")
11598                    .body(Body::empty())
11599                    .unwrap(),
11600            )
11601            .await
11602            .unwrap();
11603        let status = resp.status();
11604        let body = String::from_utf8(
11605            axum::body::to_bytes(resp.into_body(), usize::MAX)
11606                .await
11607                .unwrap()
11608                .to_vec(),
11609        )
11610        .unwrap();
11611        drop(held);
11612        assert_eq!(
11613            status,
11614            StatusCode::SERVICE_UNAVAILABLE,
11615            "a BORROWED failure verdict must fail the check, not just a freshly \
11616             measured one: {body}"
11617        );
11618        assert!(body.starts_with("FAIL"), "{body}");
11619
11620        state.db.close().await;
11621        let resp = router(state.clone())
11622            .oneshot(
11623                Request::builder()
11624                    .uri("/health")
11625                    .body(Body::empty())
11626                    .unwrap(),
11627            )
11628            .await
11629            .unwrap();
11630        assert_eq!(
11631            resp.status(),
11632            StatusCode::SERVICE_UNAVAILABLE,
11633            "a measured database failure must still fail the check"
11634        );
11635    }
11636
11637    /// **A disconnected client must not be able to cancel the probe.**
11638    ///
11639    /// Axum drops the handler future when a caller goes away. With the probe
11640    /// inline that dropped it mid-flight and released the claim WITHOUT
11641    /// recording a verdict — which let an unauthenticated caller manufacture the
11642    /// no-verdict state on demand and freeze what every other caller, including
11643    /// Fly's own check, reads. The probe runs detached now, so the verdict is
11644    /// recorded whatever happens to the request that started it.
11645    #[tokio::test]
11646    async fn an_abandoned_request_still_records_its_probe() {
11647        use crate::runtime_health::DbProbe;
11648        let state = test_state(&[]).await;
11649        let rh = state.runtime_health.clone();
11650
11651        // Drive /health and abandon it immediately — the disconnect case.
11652        let app = router(state.clone());
11653        let fut = app.oneshot(
11654            Request::builder()
11655                .uri("/health")
11656                .body(Body::empty())
11657                .unwrap(),
11658        );
11659        let handle = tokio::spawn(fut);
11660        handle.abort();
11661        let _ = handle.await;
11662
11663        // The detached probe still completes and publishes a verdict, so the
11664        // claim is free and the next caller gets a MEASURED answer.
11665        for _ in 0..50 {
11666            if rh.begin_db_probe().is_ok() {
11667                break;
11668            }
11669            tokio::time::sleep(Duration::from_millis(20)).await;
11670        }
11671        let resp = router(state.clone())
11672            .oneshot(
11673                Request::builder()
11674                    .uri("/health")
11675                    .body(Body::empty())
11676                    .unwrap(),
11677            )
11678            .await
11679            .unwrap();
11680        let body = String::from_utf8(
11681            axum::body::to_bytes(resp.into_body(), usize::MAX)
11682                .await
11683                .unwrap()
11684                .to_vec(),
11685        )
11686        .unwrap();
11687        assert!(
11688            body.contains("db: ok"),
11689            "after an abandoned request the next caller still reads an \
11690             unmeasured database — the probe was cancelled with it: {body}"
11691        );
11692        // Sanity: the type still distinguishes the three states.
11693        assert_ne!(DbProbe::Unknown, DbProbe::Ok);
11694    }
11695
11696    /// **The probe must read a real page.**
11697    ///
11698    /// `SELECT 1` compiles to `Init/Integer/ResultRow/Halt` — no `OpenRead`, so
11699    /// it never touches a b-tree and returns success against a corrupted
11700    /// database. Asserted by asking SQLite what the statement actually compiles
11701    /// to, so it survives someone "simplifying" the query later.
11702    #[tokio::test]
11703    async fn the_health_probe_opens_a_real_table() {
11704        use sqlx::Row;
11705        let state = test_state(&[]).await;
11706        // `EXPLAIN` lists the VM program; the `opcode` column is the second.
11707        let opcodes = |sql: &'static str| {
11708            let db = state.db.clone();
11709            async move {
11710                sqlx::query(sql)
11711                    .fetch_all(&db)
11712                    .await
11713                    .unwrap()
11714                    .into_iter()
11715                    .map(|r| r.get::<String, _>("opcode"))
11716                    .collect::<Vec<String>>()
11717            }
11718        };
11719
11720        // The statement `health_db_probe` really runs — it is the sole path, so
11721        // there is no second string for the handler to use instead.
11722        let explain: &'static str =
11723            Box::leak(format!("EXPLAIN {HEALTH_DB_PROBE_SQL}").into_boxed_str());
11724        let probe = opcodes(explain).await;
11725        // And the probe itself works against a real schema.
11726        assert!(
11727            health_db_probe(&state.db).await.is_ok(),
11728            "the probe does not run against the real schema",
11729        );
11730        assert!(
11731            probe.iter().any(|op| op == "OpenRead"),
11732            "the health probe reads no page; it cannot detect a broken database: {probe:?}"
11733        );
11734        // And the bare form genuinely does not, which is the whole point.
11735        let bare = opcodes("EXPLAIN SELECT 1").await;
11736        assert!(
11737            !bare.iter().any(|op| op == "OpenRead"),
11738            "premise check failed: bare SELECT 1 now reads a page: {bare:?}"
11739        );
11740    }
11741
11742    /// A fresh instance says "never", not "0" — which would read as "polled
11743    /// just now", the opposite of the truth.
11744    #[test]
11745    fn an_instance_that_has_never_polled_says_so() {
11746        assert_eq!(humanise_ago(None), "never");
11747        assert_eq!(humanise_ago(Some(0)), "0s ago");
11748        assert_eq!(humanise_ago(Some(59)), "59s ago");
11749        assert_eq!(humanise_ago(Some(60)), "1m ago");
11750        assert_eq!(humanise_ago(Some(3600)), "1h 0m ago");
11751        assert_eq!(humanise_ago(Some(11_460)), "3h 11m ago");
11752    }
11753
11754    /// A sidecar mock that answers `/internal/repo` listRecords with one saved
11755    /// record, and anything else with an empty list. Serves repeatedly.
11756    async fn spawn_saved_sidecar(saved_url: &str, saved_title: &str) -> String {
11757        use tokio::io::{AsyncReadExt, AsyncWriteExt};
11758        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
11759        let addr = listener.local_addr().unwrap();
11760        let (url, title) = (saved_url.to_string(), saved_title.to_string());
11761        tokio::spawn(async move {
11762            loop {
11763                let Ok((mut sock, _)) = listener.accept().await else {
11764                    break;
11765                };
11766                let mut buf = vec![0u8; 8192];
11767                let Ok(n) = sock.read(&mut buf).await else {
11768                    continue;
11769                };
11770                let req = String::from_utf8_lossy(&buf[..n]).to_string();
11771                let wants_saved = req.contains("community.lexicon.rss.saved");
11772                let records = if wants_saved {
11773                    serde_json::json!([{
11774                        "uri": "at://did:plc:x/community.lexicon.rss.saved/rk1",
11775                        "cid": "bafy",
11776                        "value": {
11777                            "$type": "community.lexicon.rss.saved",
11778                            "url": url,
11779                            "title": title,
11780                            "createdAt": "2026-01-01T00:00:00Z"
11781                        }
11782                    }])
11783                } else {
11784                    serde_json::json!([])
11785                };
11786                let body = serde_json::json!({
11787                    "ok": true, "data": { "records": records }
11788                })
11789                .to_string();
11790                let resp = format!(
11791                    "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
11792                    body.len(), body
11793                );
11794                let _ = sock.write_all(resp.as_bytes()).await;
11795                let _ = sock.flush().await;
11796            }
11797        });
11798        format!("http://{addr}")
11799    }
11800
11801    /// A sidecar mock serving `n` distinct saved records, none of them cached
11802    /// locally — the shape that exercises the uncached-row append.
11803    async fn spawn_saved_sidecar_many(n: usize, subscribed_feed: &str) -> String {
11804        let feed = subscribed_feed.to_string();
11805        use tokio::io::{AsyncReadExt, AsyncWriteExt};
11806        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
11807        let addr = listener.local_addr().unwrap();
11808        tokio::spawn(async move {
11809            loop {
11810                let Ok((mut sock, _)) = listener.accept().await else {
11811                    break;
11812                };
11813                let mut buf = vec![0u8; 8192];
11814                let Ok(read) = sock.read(&mut buf).await else {
11815                    continue;
11816                };
11817                let req = String::from_utf8_lossy(&buf[..read]).to_string();
11818                let records = if req.contains("community.lexicon.rss.saved") {
11819                    serde_json::Value::Array(
11820                        (0..n)
11821                            .map(|i| {
11822                                serde_json::json!({
11823                                    "uri": format!("at://did:plc:x/community.lexicon.rss.saved/rk{i}"),
11824                                    "cid": "bafy",
11825                                    "value": {
11826                                        "$type": "community.lexicon.rss.saved",
11827                                        "url": format!("https://elsewhere.example/{i}"),
11828                                        "title": format!("Elsewhere {i}"),
11829                                        "createdAt": "2026-01-01T00:00:00Z"
11830                                    }
11831                                })
11832                            })
11833                            .collect(),
11834                    )
11835                } else if req.contains("community.lexicon.rss.subscription") {
11836                    // Without this the handler's `sync_sub_refs` would REPLACE
11837                    // sub_ref with an empty set on every render, and every
11838                    // sub_ref-scoped read — including the cached starred list
11839                    // this test is about — would come back empty.
11840                    serde_json::json!([{
11841                        "uri": "at://did:plc:x/community.lexicon.rss.subscription/sub1",
11842                        "cid": "bafy",
11843                        "value": {
11844                            "$type": "community.lexicon.rss.subscription",
11845                            "url": feed,
11846                            "createdAt": "2026-01-01T00:00:00Z"
11847                        }
11848                    }])
11849                } else {
11850                    serde_json::json!([])
11851                };
11852                let body =
11853                    serde_json::json!({ "ok": true, "data": { "records": records } }).to_string();
11854                let resp = format!(
11855                    "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
11856                    body.len(), body
11857                );
11858                let _ = sock.write_all(resp.as_bytes()).await;
11859                let _ = sock.flush().await;
11860            }
11861        });
11862        format!("http://{addr}")
11863    }
11864
11865    /// **The pager must not advertise a page the clamp cannot reach.**
11866    ///
11867    /// The page clamp is computed from the CACHED total; the uncached PDS rows
11868    /// are appended to the last page rather than paged. Inflating `total` with
11869    /// them made `page_count` and the "Older →" link point one page past the end:
11870    /// requesting it clamped straight back, re-rendered the same last page, and
11871    /// still offered the link. An infinite "next" that never advances.
11872    #[tokio::test]
11873    async fn the_starred_pager_does_not_advertise_an_unreachable_page() {
11874        let did = "did:plc:pagerloop";
11875        let sidecar = spawn_saved_sidecar_many(80, "https://loop.example/feed.xml").await;
11876        let state = test_state_with_sidecar(&[], &sidecar).await;
11877        store::grant_access(&state.db, did, None, "test", None)
11878            .await
11879            .unwrap();
11880        let feed = store::upsert_feed(
11881            &state.db,
11882            &store::NewFeed {
11883                url: "https://loop.example/feed.xml".to_string(),
11884                title: Some("Loop".to_string()),
11885                ..Default::default()
11886            },
11887        )
11888        .await
11889        .unwrap();
11890        // 250 cached starred entries: the last page holds 50, so 50 + 80 > 100
11891        // and the old arithmetic reported a fourth page.
11892        let entries: Vec<store::NewEntry> = (0..250)
11893            .map(|i| store::NewEntry {
11894                guid: format!("s-{i:04}"),
11895                url: Some(format!("https://loop.example/{i}")),
11896                title: Some(format!("Starred {i:04}")),
11897                published: Some(format!("2026-06-{:02}T00:00:00Z", (i % 28) + 1)),
11898                ..Default::default()
11899            })
11900            .collect();
11901        store::insert_entries(&state.db, feed, &entries, 0)
11902            .await
11903            .unwrap();
11904        store::replace_sub_refs(&state.db, did, &[feed])
11905            .await
11906            .unwrap();
11907        for row in store::list_entries(&state.db, did, store::ListView::All, None, 1_000, 0)
11908            .await
11909            .unwrap()
11910        {
11911            store::mark_starred(&state.db, did, row.id, true)
11912                .await
11913                .unwrap();
11914        }
11915
11916        let cookie = session_cookie(&state, did, None);
11917        let app = router(state.clone());
11918        let get = |uri: &str| {
11919            let (app, cookie, uri) = (app.clone(), cookie.clone(), uri.to_string());
11920            async move {
11921                let resp = app
11922                    .oneshot(
11923                        Request::builder()
11924                            .uri(uri)
11925                            .header(header::COOKIE, cookie)
11926                            .body(Body::empty())
11927                            .unwrap(),
11928                    )
11929                    .await
11930                    .unwrap();
11931                assert_eq!(resp.status(), StatusCode::OK);
11932                String::from_utf8(
11933                    axum::body::to_bytes(resp.into_body(), 16 * 1024 * 1024)
11934                        .await
11935                        .unwrap()
11936                        .to_vec(),
11937                )
11938                .unwrap()
11939            }
11940        };
11941
11942        // 250 cached + 80 uncached = 330 rows over 4 pages. The pager and the
11943        // clamp must agree on that, and EVERY page it offers must have content —
11944        // the original bug advertised a fourth page that clamped back to the
11945        // third and re-rendered it, still offering the link.
11946        let p3 = get("/?view=starred&page=3").await;
11947        assert!(
11948            p3.contains("Page 3 of 4"),
11949            "the pager and the clamp disagree on the total: {}",
11950            p3.split("pager-pos")
11951                .nth(1)
11952                .unwrap_or("")
11953                .chars()
11954                .take(120)
11955                .collect::<String>()
11956        );
11957        // Page 3 is the boundary: the last 50 cached rows, then the first 50
11958        // uncached ones.
11959        assert!(
11960            p3.contains("Elsewhere 0"),
11961            "page 3 should start the uncached run"
11962        );
11963        assert_eq!(
11964            p3.matches("<li class=\"entry").count(),
11965            ENTRIES_PER_PAGE as usize,
11966            "the boundary page is not full"
11967        );
11968
11969        // **The heading, which the previous round broke by deleting this.**
11970        //
11971        // `total` includes the uncached records, so the parenthetical is a
11972        // SUBSET of it, not an addition — "330 entries (80 saved elsewhere)".
11973        // The version that said "plus N" double counted once `total` started
11974        // including them, and N had become page-local in the same commit while
11975        // the template stayed put. It shipped because this assertion was deleted
11976        // rather than updated.
11977        {
11978            let body = &p3;
11979            assert!(
11980                body.contains("330 entries"),
11981                "the heading must count the whole sequence: {}",
11982                body.split("content-count")
11983                    .nth(1)
11984                    .unwrap_or("")
11985                    .chars()
11986                    .take(120)
11987                    .collect::<String>()
11988            );
11989            assert!(
11990                body.contains("(80 saved elsewhere)"),
11991                "the heading must say how many of the total the cache cannot show, \
11992                 as a whole-list figure and not a per-page one: {}",
11993                body.split("content-count")
11994                    .nth(1)
11995                    .unwrap_or("")
11996                    .chars()
11997                    .take(120)
11998                    .collect::<String>()
11999            );
12000            assert!(
12001                !body.contains("plus 50") && !body.contains("plus 80"),
12002                "the heading is adding the uncached rows to a total that already \
12003                 includes them"
12004            );
12005        }
12006
12007        let p4 = get("/?view=starred&page=4").await;
12008        assert!(
12009            p4.contains("Page 4 of 4"),
12010            "page 4 was advertised but clamps somewhere else — the unreachable-page bug"
12011        );
12012        assert_eq!(
12013            p4.matches("<li class=\"entry").count(),
12014            30,
12015            "page 4 should hold the remaining 30 uncached records"
12016        );
12017        assert!(
12018            p4.contains("Elsewhere 79"),
12019            "the LAST saved record is unreachable — it can only be removed from here"
12020        );
12021
12022        // No uncached record appears on two pages.
12023        assert!(
12024            !p4.contains("Elsewhere 0"),
12025            "an uncached record was rendered on more than one page"
12026        );
12027        // Page 1 is all cached — and still reports the same whole-list heading,
12028        // because the parenthetical describes the LIST, not the page.
12029        let first = get("/?view=starred").await;
12030        assert!(
12031            first.contains("330 entries") && first.contains("(80 saved elsewhere)"),
12032            "the heading changed between pages; it describes the list, not the page"
12033        );
12034        assert!(
12035            !first.contains("Elsewhere "),
12036            "uncached saved records leaked onto the first page"
12037        );
12038    }
12039
12040    /// **A saved record whose article is not cached here is still shown.**
12041    ///
12042    /// The starred view is built from local `entries`, so before this a record
12043    /// starred in ANOTHER atproto reader — the portability the shared lexicon
12044    /// exists for — was simply invisible. It now renders from the PDS record,
12045    /// visually distinct, linking straight out.
12046    #[tokio::test]
12047    async fn a_saved_record_with_no_cached_entry_is_shown_as_a_link() {
12048        let did = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
12049        let sidecar =
12050            spawn_saved_sidecar("https://elsewhere.example/article", "Starred elsewhere").await;
12051        let mut state = test_state_with_sidecar(&[did], &sidecar).await;
12052        std::sync::Arc::get_mut(&mut state.config).unwrap().dev_did = Some(did.to_string());
12053
12054        let resp = router(state)
12055            .oneshot(
12056                Request::builder()
12057                    .uri("/?view=starred")
12058                    .body(Body::empty())
12059                    .unwrap(),
12060            )
12061            .await
12062            .unwrap();
12063        assert_eq!(resp.status(), StatusCode::OK);
12064        let body = String::from_utf8(
12065            axum::body::to_bytes(resp.into_body(), usize::MAX)
12066                .await
12067                .unwrap()
12068                .to_vec(),
12069        )
12070        .unwrap();
12071
12072        assert!(
12073            body.contains("Starred elsewhere"),
12074            "the saved record was not rendered at all"
12075        );
12076        assert!(
12077            body.contains("entry-uncached"),
12078            "it was not marked as uncached, so it looks like a normal entry"
12079        );
12080        assert!(
12081            body.contains("https://elsewhere.example/article"),
12082            "the row must link straight to the article"
12083        );
12084        assert!(
12085            !body.contains("/entries/0/"),
12086            "an uncached row must not offer entry actions against a nonexistent id"
12087        );
12088    }
12089
12090    /// **A PDS `createdAt` must not be able to panic the starred view.**
12091    ///
12092    /// `display_date` byte-sliced `p[..10]`. Every prior caller passed a
12093    /// timestamp the feed parser produced; the saved-record path passes a bare
12094    /// string off a PDS record, written by whatever client the reader used. A
12095    /// multi-byte value panicked the handler, and with no catch-panic layer the
12096    /// view stayed down until the record was removed — from that same view.
12097    #[test]
12098    fn a_multibyte_timestamp_does_not_panic_the_date_formatter() {
12099        for hostile in [
12100            "日本語日本語日本",
12101            "é",
12102            "",
12103            "2026",
12104            "🙂🙂🙂🙂🙂🙂🙂🙂🙂🙂🙂",
12105        ] {
12106            let out = display_date(Some(hostile));
12107            assert!(out.chars().count() <= 10, "{hostile:?} -> {out:?}");
12108        }
12109        assert_eq!(display_date(Some("2026-01-01T00:00:00Z")), "2026-01-01");
12110        assert_eq!(display_date(None), "");
12111    }
12112
12113    /// Unsaving makes a DPoP-signed PDS round-trip, which is the stated reason
12114    /// its neighbours are limited. It was added as a route and not added here.
12115    #[test]
12116    fn the_unsave_route_is_rate_limited() {
12117        use axum::http::Method;
12118        assert!(is_rate_limited_path("/saved/3abc/delete", &Method::POST));
12119        // And the neighbours still are.
12120        assert!(is_rate_limited_path("/entries/1/star", &Method::POST));
12121    }
12122
12123    /// **The probe detects a broken database — asserted through `/health`
12124    /// itself, not through a string.**
12125    ///
12126    /// A named constant did not bind the handler: it stayed free to call
12127    /// `query_scalar` with a different literal, so degrading the real probe to
12128    /// `SELECT 1` shipped green twice over. This drops the table the probe reads
12129    /// and asserts the endpoint stops saying `ok` — behaviour no substituted SQL
12130    /// can fake, because `SELECT 1` still succeeds against a wrecked schema.
12131    #[tokio::test]
12132    async fn health_reports_a_broken_database() {
12133        let state = test_state(&[]).await;
12134        // Sanity: healthy first, so the assertion below is about the damage.
12135        assert!(
12136            health_db_probe(&state.db).await.is_ok(),
12137            "the fixture was not healthy to begin with",
12138        );
12139
12140        sqlx::query("DROP TABLE feeds")
12141            .execute(&state.db)
12142            .await
12143            .unwrap();
12144
12145        assert!(
12146            health_db_probe(&state.db).await.is_err(),
12147            "the probe reported success against a database missing the table it \
12148             claims to read; `SELECT 1` would do exactly this",
12149        );
12150
12151        let resp = router(state)
12152            .oneshot(
12153                Request::builder()
12154                    .uri("/health")
12155                    .body(Body::empty())
12156                    .unwrap(),
12157            )
12158            .await
12159            .unwrap();
12160        let body = String::from_utf8(
12161            axum::body::to_bytes(resp.into_body(), usize::MAX)
12162                .await
12163                .unwrap()
12164                .to_vec(),
12165        )
12166        .unwrap();
12167        // The documented contract: the FIRST token is the state.
12168        assert!(
12169            body.starts_with("FAIL"),
12170            "/health did not report FAIL for a broken database: {body}",
12171        );
12172        assert!(
12173            !body.contains("db: ok"),
12174            "/health still called the database ok: {body}",
12175        );
12176    }
12177
12178    /// A sidecar mock for the OPML export: serves one subscription and one
12179    /// folder, except for the collection named in `fail_on`, which answers
12180    /// `500` — the shape a refused (short or unreadable) walk takes at this
12181    /// boundary.
12182    async fn spawn_export_sidecar(fail_on: Option<&'static str>) -> String {
12183        use tokio::io::{AsyncReadExt, AsyncWriteExt};
12184        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
12185        let addr = listener.local_addr().unwrap();
12186        tokio::spawn(async move {
12187            loop {
12188                let Ok((mut sock, _)) = listener.accept().await else {
12189                    break;
12190                };
12191                let mut buf = vec![0u8; 8192];
12192                let Ok(n) = sock.read(&mut buf).await else {
12193                    continue;
12194                };
12195                let req = String::from_utf8_lossy(&buf[..n]).to_string();
12196                let wants = |c: &str| req.contains(c);
12197                if fail_on.is_some_and(wants) {
12198                    let body = r#"{"ok":false,"error":"ShortList"}"#;
12199                    let resp = format!(
12200                        "HTTP/1.1 500 Internal Server Error\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
12201                        body.len(),
12202                        body
12203                    );
12204                    let _ = sock.write_all(resp.as_bytes()).await;
12205                    let _ = sock.flush().await;
12206                    continue;
12207                }
12208                let records = if wants(crate::lexicon::nsid::SUBSCRIPTION) {
12209                    serde_json::json!([{
12210                        "uri": "at://did:plc:exporter/community.lexicon.rss.subscription/sub1",
12211                        "cid": "bafy",
12212                        "value": {
12213                            "$type": crate::lexicon::nsid::SUBSCRIPTION,
12214                            "url": "https://kept.example/feed.xml",
12215                            "title": "Kept",
12216                            // Inside the folder, so the healthy export has to
12217                            // carry BOTH walks' results: an exporter that lost
12218                            // the folder list would flatten this outline out of
12219                            // its group with nothing else changing.
12220                            "folder": "at://did:plc:exporter/community.lexicon.rss.folder/fold1",
12221                            "createdAt": "2026-01-01T00:00:00Z"
12222                        }
12223                    }])
12224                } else if wants(crate::lexicon::nsid::FOLDER) {
12225                    serde_json::json!([{
12226                        "uri": "at://did:plc:exporter/community.lexicon.rss.folder/fold1",
12227                        "cid": "bafy",
12228                        "value": {
12229                            "$type": crate::lexicon::nsid::FOLDER,
12230                            "name": "Kept folder",
12231                            "createdAt": "2026-01-01T00:00:00Z"
12232                        }
12233                    }])
12234                } else {
12235                    serde_json::json!([])
12236                };
12237                let body =
12238                    serde_json::json!({ "ok": true, "data": { "records": records } }).to_string();
12239                let resp = format!(
12240                    "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
12241                    body.len(),
12242                    body
12243                );
12244                let _ = sock.write_all(resp.as_bytes()).await;
12245                let _ = sock.flush().await;
12246            }
12247        });
12248        format!("http://{addr}")
12249    }
12250
12251    /// A sidecar whose every `listRecords` page carries one good record and
12252    /// one with no `uri` — the #177 shape — for any collection.
12253    async fn spawn_malformed_sidecar() -> String {
12254        use tokio::io::{AsyncReadExt, AsyncWriteExt};
12255        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
12256        let addr = listener.local_addr().unwrap();
12257        tokio::spawn(async move {
12258            loop {
12259                let Ok((mut sock, _)) = listener.accept().await else {
12260                    break;
12261                };
12262                let mut buf = vec![0u8; 8192];
12263                let _ = sock.read(&mut buf).await;
12264                let body = serde_json::json!({ "ok": true, "data": { "records": [
12265                    { "uri": "at://did:plc:alerted/c/3labGOOD", "cid": "bafy", "value": {} },
12266                    { "cid": "bafy", "value": {} },
12267                ]}})
12268                .to_string();
12269                let resp = format!(
12270                    "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
12271                    body.len(),
12272                    body
12273                );
12274                let _ = sock.write_all(resp.as_bytes()).await;
12275                let _ = sock.flush().await;
12276            }
12277        });
12278        format!("http://{addr}")
12279    }
12280
12281    async fn page_body(state: AppState, did: &str, uri: &str) -> (StatusCode, String) {
12282        let cookie = session_cookie(&state, did, None);
12283        let resp = router(state)
12284            .oneshot(
12285                Request::builder()
12286                    .uri(uri)
12287                    .header(header::COOKIE, cookie)
12288                    .body(Body::empty())
12289                    .unwrap(),
12290            )
12291            .await
12292            .unwrap();
12293        let status = resp.status();
12294        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
12295            .await
12296            .unwrap();
12297        (status, String::from_utf8_lossy(&body).to_string())
12298    }
12299
12300    /// **0.4.0 step 4: a publication document with neither summary field
12301    /// renders as a title, a date and a link** — 8% of measured documents
12302    /// (37 of 449) carry neither `description` nor `textContent`. That is what
12303    /// an RSS reader shows for a title-only feed, not an error, in the list and
12304    /// on the article page alike.
12305    #[tokio::test]
12306    async fn a_publication_entry_with_no_summary_renders_title_date_and_link() {
12307        let did = "did:plc:displayer";
12308        let state = test_state(&[did]).await;
12309        let url = "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lab";
12310        let feed_id = store::upsert_feed(
12311            &state.db,
12312            &store::NewFeed {
12313                url: url.into(),
12314                title: Some("Quiet Journal".into()),
12315                ..Default::default()
12316            },
12317        )
12318        .await
12319        .unwrap();
12320        store::replace_sub_refs(&state.db, did, &[feed_id])
12321            .await
12322            .unwrap();
12323        store::insert_entries(
12324            &state.db,
12325            feed_id,
12326            &[store::NewEntry {
12327                guid: "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.document/3l2nosumaaa2a"
12328                    .into(),
12329                url: Some("https://quiet.example/no-summary".into()),
12330                title: Some("A title-only article".into()),
12331                published: Some("2026-07-11T00:00:00Z".into()),
12332                content_html: None,
12333                ..Default::default()
12334            }],
12335            0,
12336        )
12337        .await
12338        .unwrap();
12339        let (status, list) = page_body(state.clone(), did, "/?view=all").await;
12340        assert_eq!(status, StatusCode::OK);
12341        assert!(
12342            list.contains("A title-only article"),
12343            "the entry is missing from the list"
12344        );
12345
12346        let id: i64 = sqlx::query_scalar("SELECT id FROM entries WHERE feed_id = ?")
12347            .bind(feed_id)
12348            .fetch_one(&state.db)
12349            .await
12350            .unwrap();
12351        let (status, page) = page_body(state, did, &format!("/entries/{id}")).await;
12352        assert_eq!(
12353            status,
12354            StatusCode::OK,
12355            "the article page failed for an entry with no body"
12356        );
12357        assert!(page.contains("A title-only article"));
12358        assert!(
12359            page.contains("https://quiet.example/no-summary"),
12360            "no link to the original"
12361        );
12362        assert!(
12363            page.contains(r#"<time datetime=""#),
12364            "no date on the article page"
12365        );
12366    }
12367
12368    /// **#177: a malformed record in the reader's own repo is refused, and the
12369    /// reader is told.** Refusing keeps `replace_sub_refs` from dropping the
12370    /// subscription that record was; telling them keeps the stale list from
12371    /// looking like the real one. Both the reading page and the manage page.
12372    #[tokio::test]
12373    async fn a_malformed_subscription_record_raises_an_alert() {
12374        let did = "did:plc:alerted";
12375        for page in ["/", "/manage"] {
12376            let sidecar = spawn_malformed_sidecar().await;
12377            let state = test_state_with_sidecar(&[did], &sidecar).await;
12378            let (status, body) = page_body(state, did, page).await;
12379            assert_eq!(status, StatusCode::OK, "{page} did not render");
12380            assert!(
12381                body.contains(r#"role="alert""#) && body.contains("could not be read"),
12382                "{page} rendered no alert for a refused subscription list"
12383            );
12384            assert!(
12385                body.contains("1 record(s) in your subscription list"),
12386                "{page} gave the generic alert, not the malformed-record one"
12387            );
12388        }
12389    }
12390
12391    /// The control: a healthy listing raises no alert.
12392    #[tokio::test]
12393    async fn a_healthy_subscription_listing_raises_no_alert() {
12394        let did = "did:plc:exporter";
12395        let sidecar = spawn_export_sidecar(None).await;
12396        let state = test_state_with_sidecar(&[did], &sidecar).await;
12397        let (status, body) = page_body(state, did, "/").await;
12398        assert_eq!(status, StatusCode::OK);
12399        assert!(
12400            !body.contains("could not be read"),
12401            "a healthy listing raised an alert"
12402        );
12403    }
12404
12405    /// `GET /opml/export` against the mock, returning `(status, headers, body)`.
12406    async fn export_opml_response(
12407        fail_on: Option<&'static str>,
12408    ) -> (StatusCode, HeaderMap, String) {
12409        let did = "did:plc:exporter";
12410        let sidecar = spawn_export_sidecar(fail_on).await;
12411        let state = test_state_with_sidecar(&[did], &sidecar).await;
12412        let cookie = session_cookie(&state, did, None);
12413        let resp = router(state)
12414            .oneshot(
12415                Request::builder()
12416                    .uri("/opml/export")
12417                    .header(header::COOKIE, cookie)
12418                    .body(Body::empty())
12419                    .unwrap(),
12420            )
12421            .await
12422            .unwrap();
12423        let status = resp.status();
12424        let headers = resp.headers().clone();
12425        let body = String::from_utf8_lossy(
12426            &axum::body::to_bytes(resp.into_body(), usize::MAX)
12427                .await
12428                .unwrap(),
12429        )
12430        .to_string();
12431        (status, headers, body)
12432    }
12433
12434    /// **An empty export is worse than no export, and this is the caller that
12435    /// used to produce one.**
12436    ///
12437    /// `export_opml` read both walks through `unwrap_or_default()`. Now that a
12438    /// truncated walk refuses instead of returning a short list, that turned the
12439    /// refusal into `200 OK` carrying a zero-feed
12440    /// `featherreader-subscriptions.opml` — a blank backup handed over at exactly
12441    /// the moment a locked-out reader reached for one, and the changelog points
12442    /// them at this route as the recovery path.
12443    ///
12444    /// Asserts the three things a reader can actually observe: no success status,
12445    /// no download offered, and no OPML document in the body.
12446    #[tokio::test]
12447    async fn an_export_that_cannot_read_the_subscriptions_serves_no_opml() {
12448        let (status, headers, body) =
12449            export_opml_response(Some(crate::lexicon::nsid::SUBSCRIPTION)).await;
12450
12451        assert_ne!(
12452            status,
12453            StatusCode::OK,
12454            "a failed subscription walk answered 200: {body}",
12455        );
12456        assert!(
12457            !headers.contains_key(header::CONTENT_DISPOSITION),
12458            "a failed subscription walk still offered a download: {headers:?}",
12459        );
12460        assert!(
12461            !body.contains("<opml"),
12462            "a failed subscription walk still served an OPML document: {body}",
12463        );
12464    }
12465
12466    /// The folders half of the same hole. The two walks are separate calls, and
12467    /// fixing only the first leaves an export that silently loses every folder —
12468    /// a flat list that reimports as one, with no sign anything was lost.
12469    #[tokio::test]
12470    async fn an_export_that_cannot_read_the_folders_serves_no_opml() {
12471        let (status, headers, body) =
12472            export_opml_response(Some(crate::lexicon::nsid::FOLDER)).await;
12473
12474        assert_ne!(
12475            status,
12476            StatusCode::OK,
12477            "a failed folder walk answered 200: {body}",
12478        );
12479        assert!(
12480            !headers.contains_key(header::CONTENT_DISPOSITION),
12481            "a failed folder walk still offered a download: {headers:?}",
12482        );
12483        assert!(
12484            !body.contains("<opml"),
12485            "a failed folder walk still served an OPML document: {body}",
12486        );
12487    }
12488
12489    /// The other direction, without which "refuse everything" would pass both
12490    /// tests above: a healthy read still serves the file, with the feed in it.
12491    #[tokio::test]
12492    async fn a_healthy_export_serves_the_subscriptions_as_a_download() {
12493        let (status, headers, body) = export_opml_response(None).await;
12494
12495        assert_eq!(
12496            status,
12497            StatusCode::OK,
12498            "a healthy export did not answer 200"
12499        );
12500        assert_eq!(
12501            headers
12502                .get(header::CONTENT_DISPOSITION)
12503                .and_then(|v| v.to_str().ok()),
12504            Some("attachment; filename=\"featherreader-subscriptions.opml\""),
12505            "a healthy export did not offer the download",
12506        );
12507        assert!(
12508            body.contains("https://kept.example/feed.xml"),
12509            "the exported OPML lost the subscription: {body}",
12510        );
12511        assert!(
12512            body.contains("Kept folder"),
12513            "the exported OPML lost the folder: {body}",
12514        );
12515    }
12516}